refactor: design token migration + CI guards (#13173)

# 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>
This commit is contained in:
ktx-vaidehi
2026-07-20 18:06:21 +00:00
committed by GitHub
co-authored by Kirtan Thakkar Claude Fable 5 ktx-mihir
parent 8aad20b0f3
commit f15e27e5fd
1341 changed files with 25080 additions and 73593 deletions
+102 -35
View File
@@ -4,15 +4,19 @@ description: >-
Authoring guardrails for building ANY new frontend UI in the OpenObserve web
app (web/) — new views, pages, panels, dialogs, feature components, or edits
to existing ones. Enforce six house rules the moment you write Vue/template
markup: (1) use AppPageHeader for every page/module header, (2) build UI from
O2 library components in web/src/lib — never raw Quasar or bare HTML controls
when an O2 equivalent exists, (3) no hardcoded px anywhere — including inside
markup: (1) use OPageHeader for every page/module header, (2) build UI from
O2 library components in web/src/lib — never bare HTML controls when an O2
equivalent exists, (3) no hardcoded px anywhere — including inside
Tailwind class arbitrary values ([320px]) — size with rem/%/vh/vw or Tailwind's
rem-based scale, (4) no scoped-CSS blocks and no inline style="", (5) never
hardcode colors/sizes — use the modern registered --color-* design tokens and
register a new --color-* token if one is missing; the legacy --o2-* token
vocabulary is BANNED (never write var(--o2-*), never define one, never add a
.body--dark block — migrate any --o2-* you touch to its --color-* equivalent),
rem-based scale, and corner radius uses only the two-tier scale rounded-default
(4px controls) / rounded-surface (12px surfaces) / rounded-full — never
rounded-[..] or the retired rounded-sm/md/lg/xl, (4) no scoped-CSS blocks and no
inline style="", (5) never hardcode colors/sizes and never reach a token by raw
var() in a component — use the modern registered --color-* design tokens through
their utility class (bg-x/text-x), register a new --color-* token if one is
missing; the legacy --o2-* token vocabulary is BANNED (never write var(--o2-*),
never define one, never add a .body--dark block — migrate any --o2-* you touch to
its --color-* equivalent); all of this is CI-enforced and fails the build,
(6) no hardcoded user-facing text — every label, title, placeholder, and
message comes from i18n (useI18n t()) with keys added to
web/src/locales/languages/en-US.json. It also settles the recurring
@@ -33,7 +37,7 @@ description: >-
hover-flyout — how to register its route, and how to gate it for cloud /
enterprise / RBAC — even if they don't mention
these rules by name. If you are
about to type <template>, q-page, a page title, a hex color, a px value, or
about to type <template>, a page title, a hex color, a px value, or
<style scoped>, this skill applies.
---
@@ -61,29 +65,80 @@ The always-true laws. Each is stated here in brief; the full **what / why / how
code** for all six is in [references/house-rules.md](references/house-rules.md) —
read it once, it is the backbone of everything below.
1. **Every page/module header is `AppPageHeader`** — never a hand-rolled
1. **Every page/module header is `OPageHeader`** — never a hand-rolled
`<div class="header">…<h1>` or a `q-toolbar`. One header contract keeps the
title in the same place across list → detail → edit.
2. **Build from O2 components in `web/src/lib`** — never a Quasar primitive
(`q-btn`, `q-input`, `q-select`, `q-table`, …) or a bare HTML control
(`<button>`, `<input>`) when an `O*` equivalent exists. Drive them by **intent**
2. **Build from O2 components in `web/src/lib`** — never a bare HTML control
(`<button>`, `<input>`) or a third-party UI primitive when an `O*` equivalent
exists. Drive them by **intent**
(`variant` / `size` / state), never by appearance overrides.
3. **No hardcoded `px`** — size with `rem` / `%` / `vh` / `vw`, or Tailwind's
rem-based scale. This applies **inside class arbitrary values too** (`w-[320px]`,
`text-[13px]`, `gap-[6px]` are all banned — convert to `rem`). The only allowed
`px` is a `1px` hairline border/divider.
- **Font size — never `text-[..px/rem]`; pick the type-scale utility by role:**
| Utility | px | Use for |
|---|---|---|
| `text-3xs` | 10 | chart axis micro-labels, dense table sub-text (charts only) |
| `text-2xs` | 11 | tiny labels, chips, badge text |
| `text-xs` | 12 | captions, metadata, timestamps |
| `text-compact` | 13 | dense body / data tables |
| `text-sm` | 14 | **default body text** (start here) |
| `text-base` | 16 | comfortable body, form inputs |
| `text-lg` | 18 | card / panel titles |
| `text-xl` | 20 | section headings |
| `text-2xl` | 24 | page / modal titles |
| `text-3xl` | 30 | hero numbers / large display |
| `text-4xl` | 36 | display |
Default to `text-sm` for body. Go smaller only for genuinely dense/secondary UI,
larger only for titles. If a design needs a size not on the scale, snap to the
nearest step — do **not** reintroduce an arbitrary `text-[..]`.
- **Corner radius — exactly two tiers + circle, never an arbitrary value:**
`rounded-default` (**4px** — controls: buttons, inputs, chips, small icon
buttons), `rounded-surface` (**12px** — surfaces: dialogs, drawers, cards,
panels, the app-shell content area), `rounded-full` (pills / avatars / dots).
Per-corner variants use the same names (`rounded-t-surface`, `rounded-s-default`).
**Banned:** bare `rounded`, arbitrary `rounded-[10px]`, and the retired
`rounded-{sm,md,lg,xl}` / `var(--radius-{sm,md,lg,xl})` (deleted — they were
five names for one value). Pick the tier by role, not by eye.
4. **No `<style scoped>` and no inline `style=""`** — style with **bare** Tailwind
utilities (no `tw:` prefix — it was removed; no Quasar `q-*` utilities — not
available). Form-field spacing is `class="flex flex-col gap-5"` on `<OForm>`;
utilities (no `tw:` prefix — it was removed). Form-field spacing is
`class="flex flex-col gap-5"` on `<OForm>`;
omit it and fields render cramped with no spacing (the #1 "dialog looks broken"
bug).
5. **No literal colors or sizes** — use the registered `--color-*` design tokens
(token-backed utilities like `bg-surface-base` / `text-text-secondary`, or
`var(--color-*)`); register a new `--color-*` if one is missing. The legacy
**`--o2-*` vocabulary is BANNED** — never `var(--o2-*)`, never a new `--o2-*`,
never a `.body--dark` block; migrate any `--o2-*` you touch. Token files are
maintained on the tokens branch — see
[references/design-tokens.md](references/design-tokens.md).
5. **No literal colors or sizes — reach every value through a registered token,
via its utility class.** Colour comes from a `--color-*` token's **token-backed
utility** (`bg-surface-base`, `text-text-secondary`, `border-border-default`) —
**not** a raw `var(--color-*)` in a `.vue` template/`<style>` block. A raw
`var()` in a component is a counted bypass (`rawVarInComponent`) and is allowed
**only** in the sanctioned residue: `:deep()`, `@keyframes`, `color-mix()`,
`calc()`, SVG `fill`/`stroke`, and `v-html`/JS-generated markup. If a token has
no utility, register it (`@theme inline`) rather than reaching for `var()`.
**One knob per decision:** reuse an existing token before minting one, and never
add a second name for a value that already has one — an alias is a decision made
twice that silently splits adoption. The legacy **`--o2-*` vocabulary is
BANNED** — never `var(--o2-*)`, never a new `--o2-*`, never a `.body--dark`
block; migrate any `--o2-*` you touch. Raw Tailwind palette (`bg-gray-400`,
`text-red-500`) does not even compile (`palette-reset.css`), and British
`grey-*`/`primary-*` primitives in feature code are a ratcheted bypass
(`rawProjectRamp`) — use a semantic token (`text-text-secondary`, `bg-accent`),
not the ramp. See [references/design-tokens.md](references/design-tokens.md).
> **All of §3–§5 are CI-enforced and FAIL the build** — `lint:design:strict`
> (hardcoded hex/px, arbitrary radius, retired aliases, raw palette/ramp, raw
> `var()`, un-justified `<style>`, literal font stacks), `lint:tokens`,
> `lint:token-purity`, and `lint:styles` (stylelint) run on every PR. The strict
> ratchet leaves **no headroom**: a bypass count can only shrink, so new
> raw-token usage fails even in a file that still carries old debt. The
> counters scan **raw text, comments included** — a `16px` or `#fff` in a
> `<style>`-block comment, or a banned class quoted verbatim in a template/JS
> comment, counts as debt; word comments in rem/plain English instead. Fix the
> cause; don't try to raise the baseline. The flip side: a PR that *reduces*
> debt fails strict mode with "baseline is STALE" until you lock the win in —
> `cd web && node scripts/check-design-consistency.mjs --baseline` and commit
> the tightened `scripts/design-debt-baseline.json` with your change.
6. **No hardcoded user-facing text** — every label, title, placeholder, tooltip,
empty-state, toast, and validation message comes from `useI18n()`'s `t()`, with
keys added to `web/src/locales/languages/en-US.json` (other locales follow from
@@ -99,15 +154,17 @@ and each domain has its own reference below.
| Decision | The rule | Detail |
| --- | --- | --- |
| **Tabular data** | `OTable` + `OTableColumnDef[]` (never `q-table`); client-side pagination unless the backend paginates a set too large to fetch whole | [core-controls-table](references/core-controls-table.md) |
| **Whole-page layout** | `AppPageHeader` on top; a listing page is full-bleed — `flex flex-col h-full p-0`, header owns the padding, table runs flush. `p-6`/`gap-6` only for form/detail views | [page-recipes](references/page-recipes.md) |
| **Tabular data** | `OTable` + `OTableColumnDef[]`; client-side pagination unless the backend paginates a set too large to fetch whole | [core-controls-table](references/core-controls-table.md) |
| **Whole-page layout** | **Every routed view is a `OPageLayout`.** It's the ONE page component — it owns the full-height column, the header (from `:title`/`:icon`/`:subtitle`/`:back` props + `#actions`/`#header-tabs`), an optional `#subnav` strip, an optional `#sidebar` rail (fixed or `resizable`), and the body's inset. You plug in data; there's no place to hand-roll a padded `<div>`. Body is inset to the page-edge grid by default — pass **`bleed`** for a full-bleed body (an `OTable`, a chart, a `router-view` shell), or **`constrained`** for a centered reading column (forms). The `#header` slot is a rare escape hatch only. | [page-recipes](references/page-recipes.md) |
| **Content inset** | `OPageLayout` already insets the body. Anywhere else (a panel, a dialog section, one tab's content) wrap it in **`OContent`** (bakes the one `px-page-edge` grid line, the primitive `OPageLayout` uses internally) instead of hand-picking `px-2`/`px-4`/`p-2.5`; pass `bleed` (or `bleed-x`/`bleed-y`) for full-bleed content that owns its own edge — same escape-hatch idea as `ODrawer`/`ODialog` `bleed`. Never hand-roll a content inset. | [conventions](references/conventions.md) |
| **Tab strips** | an `OTabs` strip needs **no** horizontal wrapper padding — the first tab's label self-aligns to the `px-page-edge` grid, so it lines up with the `OContent` body below it. Put the strip's bottom divider on the strip (`border-b`) and give it no `px-*`; wrapping a tab strip in `px-page-edge` double-insets the labels. | [conventions](references/conventions.md) |
| **Listing toolbar** | every list carries three affordances — search + filters (`#toolbar`), refresh (`#toolbar-trailing`), and the auto-injected column-visibility toggle; empty state is one `OEmptyState` with `:filtered` | [page-recipes](references/page-recipes.md) |
| **Data fetching** | view → domain service (`src/services`, via the `http.ts` wrapper) → Vuex (shared/cached) or local `ref` (ephemeral); never call `http`/axios from a component | [conventions](references/conventions.md) |
| **Form container** | confirm → `ConfirmDialog`; short form → `ODialog`; tall or contextual form → `ODrawer`; primary multi-section flow → a full in-page view. Never `q-dialog`/`q-drawer` | [conventions](references/conventions.md) |
| **Form container** | confirm → `ConfirmDialog`; short form → `ODialog`; tall or contextual form → `ODrawer`; primary multi-section flow → a full in-page view. Use `ODialog` / `ODrawer` for these | [conventions](references/conventions.md) |
| **Form validation** | `OForm` + a colocated Zod `<Form>.schema.ts`; fields are `OForm*` bound **only by `name=`** (no `v-model`/`ref` mirror, no `formData`); submit + loading automatic; payload built with explicit keys; field arrays use `:key="index"` | [forms-validation](references/forms-validation.md) |
| **New page in nav** | a route **+ exactly one** surface (rail item / flyout child / Settings / IAM sub-page) **+** an env/role gate — the route condition, the nav-entry gate, and the SectionRail `visible` all express the same rule | [navigation-menus](references/navigation-menus.md) |
| **Keyboard shortcuts** | registry-driven — declare in `shortcutRegistry.ts`, bind with `useShortcuts([{ id, handler }])`; never an ad-hoc `keydown` listener or a hardcoded `⌘N` in a template | [keyboard-shortcuts](references/keyboard-shortcuts.md) |
| **Cancel / Save row** | cancel = `variant="outline"`, save = `variant="primary"`, both `size="sm-action"`, spaced with `gap-2` on the parent (never `q-ml-*`/`<q-space>`) | [conventions](references/conventions.md) |
| **Cancel / Save row** | cancel = `variant="outline"`, save = `variant="primary"`, both `size="sm-action"`, spaced with `gap-2` on the parent | [conventions](references/conventions.md) |
| **Nothing fits** | build a reusable component — generic primitive → a new `O*` in `web/src/lib`; app-specific composition → a named component in `web/src/components`. Never hand-assemble `<div>` + utility classes to fake a component | [creating-components](references/creating-components.md) |
**Dark mode is automatic** — every O2 component and token resolves correctly in
@@ -130,17 +187,18 @@ don't guess a name.
Run this in your head before writing template markup, and again before
considering the UI done:
- [ ] Page/module header is `AppPageHeader` (not a hand-built header bar).
- [ ] Page/module header is `OPageHeader` (not a hand-built header bar).
- [ ] Every interactive control is an O2 component if one exists in
`web/src/lib` — no stray `q-*` or bare HTML controls with an O2 equivalent.
`web/src/lib` — no bare HTML controls or third-party primitives with an O2 equivalent.
- [ ] A self-contained/repeated UI element with no matching component was
**built as a reusable component** (generic → `O*` in `web/src/lib`;
app-specific → named component in `web/src/components`), not hand-assembled
from `<div>` + utility classes. Classes are for layout only.
- [ ] Tabular data uses `OTable` with `OTableColumnDef[]` columns (never
`q-table`); server mode only for backend-paginated data.
- [ ] Tabular data uses `OTable` with `OTableColumnDef[]` columns; server mode
only for backend-paginated data.
- [ ] Listing page uses the **full-height flush skeleton** (root
`flex flex-col h-full p-0`, header `shrink-0 px-4 border-b`, table wrapper
`flex flex-col h-full p-0`, header `shrink-0 border-b` — OPageHeader bakes
in its own `px-page-edge`, never add a `px-*`, table wrapper
`card-container flex-1 min-h-0 overflow-hidden`, `OTable :frame="false"`) —
not a `p-6` padded container; table runs flush.
- [ ] Listing page has all three toolbar affordances: **search + filters**
@@ -171,9 +229,18 @@ considering the UI done:
- [ ] Zero `px` values (except a `1px` hairline) — including inside class
arbitrary values (`w-[320px]`, `text-[13px]`). Sizes use rem / % / vh / vw
or Tailwind's rem scale.
- [ ] Corner radius is `rounded-default` / `rounded-surface` / `rounded-full`
only — no bare `rounded`, no `rounded-[..]`, no retired
`rounded-{sm,md,lg,xl}`.
- [ ] No `<style scoped>` block added. No `style="…"` attribute added.
- [ ] No literal colors anywhere. Colors come from `--color-*` token utilities /
`var(--color-*)`.
- [ ] No literal colors anywhere. Colours come from `--color-*` token **utilities**
(`bg-surface-base`, `text-text-secondary`) — not a raw `var(--color-*)` in a
component (that's a `rawVarInComponent` bypass, allowed only in `:deep`,
keyframes, `color-mix`, `calc`, SVG `fill`/`stroke`, `v-html`). No raw
Tailwind palette (`bg-gray-*`) and no raw `grey-*`/`primary-*` ramp — use a
semantic token.
- [ ] `cd web && npm run lint:design:strict` passes (the strict ratchet — no new
raw-token bypass anywhere, even in files with existing debt).
- [ ] **No `--o2-*` anywhere** — no `var(--o2-*)`, no new `--o2-*` definition, no
`.body--dark` block. Any `--o2-*` in code you touched was migrated to its
`--color-*` equivalent.
@@ -196,13 +263,13 @@ not the call site":
from divs + classes at the call site. Generic primitive → a new `O*` in
`web/src/lib`; app-specific composition → a named component in
`web/src/components`. When *migrating* an existing element and can't build it
yet, keep the current Quasar/HTML element in place and flag it. Never
yet, keep the current element in place and flag it. Never
substitute a bare `<div>`. See
[references/creating-components.md](references/creating-components.md) and the
"No component fits?" section of [references/conventions.md](references/conventions.md).
- Missing O2 variant → add the variant to the component source, then use it.
- Missing token → register it in the token CSS (rule 5), then use it.
- `AppPageHeader` can't express the header → change `AppPageHeader`, not the page.
- `OPageHeader` can't express the header → change `OPageHeader`, not the page.
If you genuinely believe a rule shouldn't apply to a specific case, say so
explicitly and explain why, rather than silently introducing a `px`, a hex, or a
@@ -35,7 +35,7 @@ markup — don't guess a prop name.
| I need to… | Use | Reference |
| --- | --- | --- |
| Trigger an action (submit, add, open) | `OButton` (`OButtonGroup` for a set) | core-controls-table |
| Show a page/module header | `AppPageHeader` (rule 1) | — |
| Show a page/module header | `OPageHeader` (rule 1) | — |
| Render tabular data | `OTable` + `OTableColumnDef[]` | core-controls-table |
| Show a status label / count / tag | `OBadge`, `OTag`, `ODimensionChip` | core-display |
| Group content in a surface | `OCard` (+ `OCardSection`, `OCardActions`) | core-display |
@@ -12,7 +12,8 @@ screen on the same rhythm: `gap-2` = 0.5rem, `gap-4` = 1rem, `gap-5` = 1.25rem,
### Listing Pages (OTable, Lists, Grids)
A listing/table page is **full-bleed**, not a padded card: root `flex flex-col
h-full p-0`, the `AppPageHeader` owns the padding (`shrink-0 px-4 border-b`), and
h-full p-0`, the `OPageHeader` owns the padding — it **bakes in `px-page-edge`
itself**, so the consumer adds only `shrink-0 border-b` (never a `px-*`), and
the table wrapper runs flush (`card-container flex-1 min-h-0 overflow-hidden`
around `<OTable :frame="false">`). **Reserve `p-6`/`gap-6` for form/detail
views** — a page inset breaks the flush table. The full skeleton, the mandatory
@@ -36,7 +37,7 @@ and [references/page-recipes.md](page-recipes.md).
### Drawer Spacing (`ODrawer`)
- **Detail (read-only) section** at the top: group facts in token-backed cards —
`class="flex flex-col gap-1.5 p-3 rounded-md bg-surface-subtle"` per card
`class="flex flex-col gap-1.5 p-3 rounded-surface bg-surface-subtle"` per card
(label + value). Only distinct groups get a background; don't shade every line.
- **Separate detail from the edit form** with a divider on the detail wrapper:
`class="… mb-8 pb-8 border-b border-border-default"`.
@@ -78,9 +79,7 @@ patching it from outside. Banned on an O2 component:
| --- | --- | --- |
| `style="color: #fff; padding: 10px"` | hardcoded colors/px, bypasses tokens | use `variant` / `size` / component design |
| `class="tw:px-2 tw:text-sm"` (`tw:` prefix) | prefix removed — `tw:*` doesn't resolve | use component `variant`/`size` props |
| `class="q-pa-md text-weight-bold"` (Quasar) | Quasar utilities not available | use component `variant`/`size` props |
| `<style scoped>` targeting its class | invisible fork of the design system | move the intent into a `variant` |
| `class="q-ml-sm"` / `q-mr-sm` for spacing | Quasar spacing leak | bare `gap-*` on the parent container |
Decision flow for any visual that differs from the default:
1. Expressible via `variant` / `size`? → use the prop.
@@ -108,8 +107,8 @@ it's what makes action rows look identical everywhere:
- Cancel is **always** `variant="outline"` — never `secondary` or
`ghost-primary`. Save/Submit/OK is **always** `variant="primary"`.
- Both use `size="sm-action"`. Space them with `gap-2` on the parent — never
`q-ml-*`/`q-mr-*` on the button, never a `<q-space />` between them.
- Both use `size="sm-action"`. Space them with `gap-2` on the parent — use
`gap-*` on the parent container instead of per-child margins or spacer elements.
- Other actions in the same row (Delete, Reject) keep their own `variant`
(e.g. `destructive`) but match `size="sm-action"` for alignment.
- When the button row lives in an `ODialog`/`ODrawer` footer, prefer the
@@ -177,11 +176,12 @@ differently on every screen.
Lay out a whole page from a recipe, not from scratch — full recipes in
[references/page-recipes.md](page-recipes.md). The essentials:
- **Every page** = `AppPageHeader` on top (primary New/Add action in `#actions`).
- **Every page** = `OPageHeader` on top (primary New/Add action in `#actions`).
- **A listing/table page is full-bleed**, laid out as a full-height flex column
where the **header carries the padding** and the **table runs flush** to the
content edges — root `flex flex-col h-full p-0`, header
`shrink-0 px-4 border-b border-border-default`, table wrapper
where the **header carries the padding** (baked into `OPageHeader` as
`px-page-edge`) and the **table runs flush** to the content edges — root
`flex flex-col h-full p-0`, header
`shrink-0 border-b border-border-default` (no `px-*` — the header self-insets), table wrapper
`card-container flex-1 min-h-0 overflow-hidden` around `<OTable :frame="false">`.
**Not** a `gap-6 p-6` padded container (that's for form/detail views).
- **A listing/table page always carries three toolbar affordances** on its
@@ -212,8 +212,7 @@ Lay out a whole page from a recipe, not from scratch — full recipes in
### Tables → `OTable`
**What.** All tabular data uses `OTable`
(`@/lib/core/Table/OTable.vue`). Legacy `q-table` is fully retired (zero usages
left) — never reintroduce one.
(`@/lib/core/Table/OTable.vue`) — never hand-build a `<table>`.
**How.** Columns are `OTableColumnDef[]` (`:columns` + `:data`); reuse the shared
width constants (`COL.*`, `TABLE_INDEX_COL_SIZE`) and the prebuilt cell renderers
@@ -229,7 +228,7 @@ prop/slot/emit list and the cell renderers are in
[references/core-controls-table.md](core-controls-table.md).
**Why.** One table component means one behavior for sort, resize, pin, empty,
loading, and dark mode — a hand-built `<table>` or a fresh `q-table` re-answers all
loading, and dark mode — a hand-built `<table>` re-answers all
of that inconsistently and re-implements pagination you'd then have to retest.
### Where code goes (project layering)
@@ -323,7 +322,7 @@ Rules of thumb:
`primary` / `secondary` / `neutralButtonLabel` footer API instead of a manual
button row — while still honoring the cancel=`outline` / save=`primary`
standard (see [§ Working with O2 components](#working-with-o2-components)).
- Never use `q-dialog` / `q-drawer` — retired.
- Use `ODialog` / `ODrawer` for these.
**Why.** Users learn a spatial grammar: a small ask is a centered modal, a
working surface is a side drawer, a big task gets its own page. Mixing those up
@@ -153,7 +153,8 @@ Two-way binding uses the standard `modelValue` prop + `update:modelValue` emit (
(Full token guide, registration steps, and the `--o2-*` → `--color-*` map:
[design-tokens.md](design-tokens.md).)
- **Colors: only the modern `--color-*` tokens.** Reach them through token utilities (`bg-surface-panel`, `text-text-primary`, `bg-button-primary`) or `var(--color-*)` in a CSS file. The legacy `--o2-*` vocabulary is **banned** — never use it, never define it, and never add a `.body--dark` block.
- **Colors: only the modern `--color-*` tokens.** Reach them through token utilities (`bg-surface-panel`, `text-text-heading`, `bg-button-primary`) or `var(--color-*)` in a CSS file / component-token block (never a raw `var()` in the `.vue` template — see next bullet). The legacy `--o2-*` vocabulary is **banned** — never use it, never define it, and never add a `.body--dark` block. Retired aliases `text-text-primary`/`-caption` → `text-text-heading`/`-secondary`.
- **Corner radius: `rounded-default` (controls) / `rounded-surface` (card/panel surfaces) / `rounded-full`** — never `rounded-[..]` or the retired `rounded-{sm,md,lg,xl}`. A reusable card/panel component rounds with `rounded-surface`.
- **No hardcoded px anywhere** — including inside Tailwind arbitrary values. `w-[320px]` is banned. Use the rem-based scale (`w-80`, `h-10`, `px-4`) or `rem` / `%` / `vh` / `vw`. `1px` hairline borders are the only exception.
- **No `var(--*)` in `.vue` templates** and **no hex in components** — go through token utilities.
@@ -211,7 +212,7 @@ Model the new `OFormX` on `web/src/lib/forms/Input/OFormInput.vue`. Never mirror
Ordered, but lightweight. **No code before analysis.**
1. **Analysis** — confirm the component doesn't already exist in `lib/`; if you're replacing a Quasar element, grep every usage/prop/slot/variant across `web/src/` and list them. The goal: enumerate every real visual pattern so the variant set covers them all.
1. **Analysis** — confirm the component doesn't already exist in `lib/`; if you're replacing an existing element, grep every usage/prop/slot/variant across `web/src/` and list them. The goal: enumerate every real visual pattern so the variant set covers them all.
2. **Design** — from the analysis, define the minimal `variant`/`size`/state prop set (§5), scope the full family (§3), and write the `.types.ts` contract first. Every observed visual pattern must map to a named variant or be an intentionally dropped prop.
3. **Implement** — write the `.vue` with token utilities and the computed variant map; register any new tokens (§7); reach for `reka-ui` on ARIA-complex behavior (§4). Add a `data-test` (`<module>-<file>-<descriptor>`, kebab-case) to every interactive/key element.
4. **Test** — write the `.spec.ts` (§10).
@@ -20,7 +20,7 @@ set. The legacy **`--o2-*`** set is banned (see the bottom of this file).
- **Prefer the token-backed Tailwind utility** generated from the `--color-*`
token — bare, **no `tw:` prefix** (the prefix was removed from this project;
`tw:bg-surface-base` no longer resolves): `bg-surface-base`, `text-text-primary`,
`tw:bg-surface-base` no longer resolves): `bg-surface-base`, `text-text-heading`,
`border-border-default`, `bg-button-primary`, `bg-tabs-active-bg`. Grep existing
views for the names already in use.
- **Don't hand-write the arbitrary-value form when a utility exists.** Every
@@ -29,12 +29,15 @@ set. The legacy **`--o2-*`** set is banned (see the bottom of this file).
guaranteed to exist and compiles to the **identical** CSS — the arbitrary value
is just noisier. The utility name is the token minus the `--color-` prefix:
```html
<!-- avoid --> <div class="bg-[var(--color-surface-base)] text-[var(--color-text-primary)] border-[var(--color-border-default)]">
<!-- prefer --> <div class="bg-surface-base text-text-primary border-border-default">
<!-- avoid --> <div class="bg-[var(--color-surface-base)] text-[var(--color-text-heading)] border-[var(--color-border-default)]">
<!-- prefer --> <div class="bg-surface-base text-text-heading border-border-default">
```
(`--color-text-primary` → `text-text-primary`, `--color-surface-base` →
(`--color-text-heading` → `text-text-heading`, `--color-surface-base` →
`bg-surface-base`, `--color-border-default` → `border-border-default`.) This also
covers the v4 shorthand `bg-(--color-x)` — same rule, use `bg-x`.
> **Retired aliases** (CI-banned, `retiredTextAlias`): `text-text-primary` →
> `text-text-heading`, `text-text-caption` → `text-text-secondary`. The text
> hierarchy is heading / body / secondary / label / muted.
- **Arbitrary `[var(--color-x)]` in a class is acceptable in only two cases:**
1. the token has **no registered utility** — a var-only token defined in a plain
`:root {}` and *not* re-declared in an `@theme inline` block (most domain /
@@ -47,12 +50,86 @@ set. The legacy **`--o2-*`** set is banned (see the bottom of this file).
Otherwise, use the utility. (If you find yourself reaching for an arbitrary value
for a token that *should* be a first-class utility, register it in `@theme inline`
— see below — rather than scattering `[var(--color-x)]`.)
- **Need the raw variable** (in a CSS file, or a rare `:style` binding for a
computed value)? Use the modern custom property only: `var(--color-text-primary)`,
`var(--color-surface-base)`. Never `var(--o2-*)`.
- **Raw `var(--color-*)` in a `.vue` file is a counted bypass** (`rawVarInComponent`,
a ratcheted CI category). It is allowed **only** in the sanctioned residue where no
utility can reach: inside `:deep()`, `@keyframes`, `color-mix()`, `calc()`, SVG
`fill`/`stroke`, and `v-html`/JS-generated markup (e.g. an ECharts tooltip string).
Everywhere else use the utility; if the utility doesn't exist, **register the token**
(below) instead of reaching for `var()`. In a standalone `.css` file, `var(--color-*)`
is fine. Never `var(--o2-*)`.
- **Never a literal** — no hex / `rgb()` / `rgba()` / `hsl()` / named colors in a
component, and no magic `px` dimensions. (Literal hex is allowed in exactly one
place: the raw palette in `base.css`, which nothing else references directly.)
Raw Tailwind palette utilities (`bg-gray-400`, `text-red-500`) don't even compile —
`palette-reset.css` clears the `--color-*` default namespace so only registered
tokens survive.
- **Reuse before minting; one knob per decision.** Grep for an existing token first;
mint a new one only when it's genuinely needed and used. Never add a second name for
a value that already has one — an alias is a decision made twice that splits adoption
and can't be seen in review.
- **British `grey-*`, not `gray-*`** — `grey-*`/`primary-*` are our project ramps; using
them raw in feature code is a ratcheted bypass (`rawProjectRamp`) — prefer a semantic
token (`text-text-secondary`, `bg-accent`). `gray-*` is Tailwind's un-themed default and
is banned (`rawPalette`).
## Corner radius — two tiers + circle
Radius is a token too, and it has exactly **three** app-facing values. Pick by role;
never eyeball an arbitrary radius.
| Utility | Token | Value | Use for |
|---|---|---|---|
| `rounded-default` | `--radius-default` | 4px | controls — buttons, inputs, chips, small icon buttons |
| `rounded-surface` | `--radius-surface` | 12px | surfaces — dialogs, drawers, cards, panels, the app-shell content area |
| `rounded-full` | `--radius-full` | ∞ | pills, avatars, status dots |
Per-corner variants use the same names (`rounded-t-surface`, `rounded-s-default`).
**Banned (all CI-enforced):** bare `rounded`, arbitrary `rounded-[10px]`, and the
retired `rounded-{sm,md,lg,xl}` / `var(--radius-{sm,md,lg,xl})` — they were five names
for one value and were deleted (`sm`/`md` → `default`, `lg`/`xl` → `surface`). Change
`--radius-default` or `--radius-surface` in `base.css` to retune a whole tier at once.
## Enforced by CI (it will fail the build)
None of the above is advisory. Every PR runs, as **required** gating steps:
- `lint:design:strict` — the design-consistency ratchet in **strict** mode. It counts
every bypass category (hardcoded hex/px, `arbRadius`, `bareRounded`, `retiredTextAlias`,
`retiredRadiusAlias`, `rawPalette`, `rawProjectRamp`, `rawVarInComponent`,
un-justified `<style>`, …). Zero-tolerance categories fail on the **first** occurrence;
ratcheted categories can **only shrink** — and strict mode leaves **no headroom**, so a
new raw token fails even in a file that still carries old debt. You cannot "use up" slack.
- `lint:tokens` — every `var(--x)` must resolve to a defined token (no silent voids),
and **any** `--o2-*` used as a CSS custom property fails, fallback or not.
- `lint:token-purity` — the token files stay pure (no class defs leaking in).
- `lint:styles` (stylelint) — `color-no-hex` in `.vue` styles, the `.dark`-only
dark-selector rule, banned `var(--o2-*)` / retired `var(--color-text-primary/-caption)`
values, and **literal font stacks** (`font-family` must be `var(--font-sans)` /
`var(--font-mono)` — the design script's `literalFontFamily` category counts the
same thing in class arbitrary values), all at **error** severity.
If a rule blocks you, fix the cause (use the utility, register the token, pick the tier) —
raising the baseline is not the move.
**The counters scan raw text — comments included.** A `16px` / `#fff` /
`var(--color-*)` mentioned in a `<style>`-block comment, or a banned class pattern
(`bg-gray-400`, `tw:`, `w-[320px]`, a retired alias) quoted verbatim in a
template/JS comment, counts as debt exactly like real code and can fail
`lint:design:strict`. Describe values in comments in `rem` or plain words instead
of quoting the banned form.
**The ratchet cuts both ways — improving also needs a baseline update.** In
`--strict` mode the baseline must *equal* reality, so a PR that *reduces* debt
(migrates a `var()`, deletes a debt-carrying file or `<style>` block) fails CI with
"baseline is STALE". That failure is expected and good — lock the win in:
```bash
cd web && node scripts/check-design-consistency.mjs --baseline
# commit the tightened scripts/design-debt-baseline.json with your change
```
Never hand-edit the baseline, and never re-baseline to *absorb a regression* — only
to record an improvement (CI's regression check fails first if counts went up).
## The token files
@@ -62,7 +139,7 @@ Plain CSS, Tailwind v4, **no SCSS**. They live in
| File | Holds |
| --- | --- |
| `base.css` | raw palette primitives (`--color-grey-*`, `--color-primary-*`, radius, shadow) + `@font-face` — the only place literal hex lives |
| `semantic.css` | semantic/intent `--color-*` tokens, light `:root` (e.g. `--color-text-primary`, `--color-surface-base`, `--color-border-default`) |
| `semantic.css` | semantic/intent `--color-*` tokens, light `:root` (e.g. `--color-text-heading`, `--color-surface-base`, `--color-border-default`) |
| `component.css` | per-component `--color-*` tokens (e.g. `--color-button-primary`) |
| `dark.css` | **all** dark-mode overrides, under `.dark` |
@@ -102,7 +179,7 @@ Never inline the literal you would have registered.
There are two token vocabularies in the codebase: the modern `--color-*` set
(registered in `@theme inline`, drives Tailwind utilities, proper `.dark`
overrides) and a legacy **`--o2-*`** set (Quasar-era, `var()`-only, with its own
overrides) and a legacy **`--o2-*`** set (retired, `var()`-only, with its own
drifting `.body--dark` values). The legacy set is **being deleted**, and lint/CI
is being wired to **fail the build** on any `--o2-*`. Treat it as already
forbidden:
@@ -124,10 +201,10 @@ Most legacy tokens map to a same-meaning modern token — the heuristic is simpl
| Legacy `--o2-*` | Modern `--color-*` |
| --- | --- |
| `--o2-text-heading` | `--color-text-heading` |
| `--o2-text-primary` / `--o2-text-4` | `--color-text-primary` |
| `--o2-text-primary` / `--o2-text-4` | `--color-text-heading` |
| `--o2-text-body` | `--color-text-body` |
| `--o2-text-secondary` / `--o2-text-2` | `--color-text-secondary` |
| `--o2-text-caption` / `--o2-text-1` | `--color-text-caption` |
| `--o2-text-caption` / `--o2-text-1` | `--color-text-secondary` |
| `--o2-text-label` / `--o2-text-3` | `--color-text-label` |
| `--o2-text-muted` | `--color-text-muted` |
| `--o2-text-placeholder` | `--color-text-placeholder` |
@@ -147,7 +224,9 @@ Most legacy tokens map to a same-meaning modern token — the heuristic is simpl
| `--o2-secondary-btn-bg` / `-text` / `-border` | `--color-button-secondary` / `-foreground` / `-border` |
| `--o2-hover-accent` / `--o2-interactive-hover` | `--color-interactive-hover-bg` |
**Authoritative source (always in-tree):** the token files themselves define both
**Authoritative source (always in-tree):** the codemod's machine map is
`web/scripts/o2-token-map.json` (`migrate` key — this is what the CI failure message
points at), and the token files themselves define both
vocabularies — `semantic.css` (light `--o2-*` and `--color-*` values) and
`dark.css` (dark values). If a legacy token isn't in the table above, grep those
files for its value and find the `--color-*` token that carries the same value:
@@ -41,7 +41,7 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
**Use when:** A single-line (or, via `type="textarea"`, multi-line) free-text field — text, password, email, number, url, tel, search — with optional prefix/suffix, icons, clear button, mask, debounce, and character counter.
**Don't use for:** Choosing from a fixed option list (use `OSelect`); typeahead over a suggestion list where free text is also allowed (use `OCombobox`); a pure filter/search box in a toolbar (use `OSearchInput`, which wraps this with a search icon).
**Key props:** `modelValue` (`string | number`), `type` (`"text"` default | `"password"` | `"email"` | `"number"` | `"search"` | `"url"` | `"tel"` | `"textarea"`), `label`, `placeholder`, `helpText`, `errorMessage`, `error` (boolean — error styling only shows when `error` is true; a bare `errorMessage` alone will not surface), `prefix`, `suffix`, `clearable` (default `false`), `readonly` (default `false`), `disabled` (default `false`), `required` (renders `*` after label — do not append ` *` manually), `autofocus` (default `false`), `debounce` (ms; flushed on blur), `autogrow` (default `false`, textarea only), `mask` (`"time"` | `"fulltime"` | `"DD-MM-YYYY"` — digit-extracting), `maxlength` (shows counter), `rows` (default `3`, textarea), `size` (`"sm"` | `"md"` default), `width` (`"xs"` | `"sm"` | `"md"` | `"lg"` | `"full"` default), `labelPosition` (`"inside"` | `"outside"` default), `modelModifiers` (`{ number?, trim? }`), `id`, `name`, `autocomplete`.
**Slots:** `icon-left`, `icon-right`, `prefix`, `suffix`, `tooltip` (put a `<q-tooltip>` inside — renders an info icon in the label). (`append` is also consumed in template.)
**Slots:** `icon-left`, `icon-right`, `prefix`, `suffix`, `tooltip` (put a tooltip element inside — renders an info icon in the label). (`append` is also consumed in template.)
**Emits:** `update:modelValue`, `clear`, `blur`, `focus`, `keydown`, `keyup`, `keypress`
**Example:**
```vue
@@ -228,7 +228,7 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
**Import:** `@/lib/forms/Checkbox/OCheckbox.vue`
**Use when:** A single boolean (or tri-state `indeterminate`) checkbox, or a member of an `OCheckboxGroup` (set `value`/`val` and it reads the group's checked array). Supports custom `trueValue`/`falseValue`/`indeterminateValue`.
**Don't use for:** A set of related checkboxes with a shared array model (wrap them in `OCheckboxGroup`, or use `OOptionGroup type="checkbox"` for a list-driven layout); an on/off toggle switch (use `OSwitch`).
**Key props:** `modelValue` (`boolean | "indeterminate" | string | number | (string|number)[]`), `value` (member value in a group; excludes boolean by design), `val` (q-checkbox alias for `value`), `label`, `size` (`"xs"` | `"sm"` | `"md"` default), `trueValue`/`falseValue`/`indeterminateValue` (custom-value mode), `color` (`"primary"` default | `"negative"`), `disabled` (default `false`), `required`, `id`, `name`.
**Key props:** `modelValue` (`boolean | "indeterminate" | string | number | (string|number)[]`), `value` (member value in a group; excludes boolean by design), `val` (compatibility alias for `value`), `label`, `size` (`"xs"` | `"sm"` | `"md"` default), `trueValue`/`falseValue`/`indeterminateValue` (custom-value mode), `color` (`"primary"` default | `"negative"`), `disabled` (default `false`), `required`, `id`, `name`.
**Slots:** `label` (custom label content — overrides `label` prop)
**Emits:** `update:modelValue`, `change` (emits the item value when inside a group)
**Example:**
@@ -290,7 +290,7 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
**Import:** `@/lib/forms/Radio/ORadio.vue`
**Use when:** A single radio button representing one `value` — **must** be used inside `ORadioGroup` (the group provides the RadioGroupRoot context and value map).
**Don't use for:** Standalone use outside a group (it has no own model); a multi-select (use checkboxes); a compact options-array layout (use `OOptionGroup type="radio"`).
**Key props:** `value` (`string | number | boolean` — this radio's value, compared against the group's `modelValue`), `val` (q-radio alias for `value`), `label`, `size` (`"xs"` | `"sm"` | `"md"` default), `disabled` (default `false`), `id`
**Key props:** `value` (`string | number | boolean` — this radio's value, compared against the group's `modelValue`), `val` (compatibility alias for `value`), `label`, `size` (`"xs"` | `"sm"` | `"md"` default), `disabled` (default `false`), `id`
**Slots:** `label` (custom label content)
**Emits:** none (selection flows through the parent `ORadioGroup`)
**Example:**
@@ -340,7 +340,7 @@ Because the wrapper is `Omit<..., "modelValue" | ...>`, you never pass `v-model`
### OSwitch
**Import:** `@/lib/forms/Switch/OSwitch.vue`
**Use when:** An on/off toggle (the O2 replacement for `q-toggle`) — instant enable/disable of a setting. Supports custom `checkedValue`/`uncheckedValue` and label placement.
**Use when:** An on/off toggle — instant enable/disable of a setting. Supports custom `checkedValue`/`uncheckedValue` and label placement.
**Don't use for:** A checkbox in a list/form validation context (use `OCheckbox`); choosing among 3+ options (use `ORadioGroup`/`OOptionGroup`).
**Key props:** `modelValue` (`boolean | string | number`), `label`, `labelPosition` (`"left"` | `"right"` default), `size` (`"sm"` | `"md"` default | `"lg"` | `"xl"`), `checkedValue` (custom on-value — string/number only), `uncheckedValue` (custom off-value), `disabled` (default `false`), `required`, `id`, `name`
**Slots:** `label` (custom label content — overrides prop), `tooltip` (info icon inline in label)
@@ -32,7 +32,7 @@ The `OForm` container + `useOForm` composable own field registration, validation
- `defaultValues` (`T` — required on the normal path; supplied via `useOForm()` on the headless `:form` path) — initial values for every field; keys must match each field's `name`
- `schema` (`unknown` — Zod / Standard Schema; optional) — validates the whole form, mapped to fields by `name`
- `onSubmit` (`(values: T) => unknown | Promise<unknown>`) — written `@submit="handler"` in templates; awaited
- `greedy` (`boolean`, default `false`) — validate every field instead of stopping on the first error (mirrors q-form `greedy`)
- `greedy` (`boolean`, default `false`) — validate every field instead of stopping at the first error
- `form` (`OFormInstance`) — an externally-created form from `useOForm()`; when present OForm uses it instead of creating its own
**Slots:** default only — scoped as `{ isSubmitting, canSubmit }`.
@@ -8,20 +8,20 @@ Each rule below states **what**, **why**, and **how**. The "why" matters: these
aren't arbitrary — each one exists because breaking it produces a specific,
recurring class of bug or drift in this codebase.
### 1. Every page/module header is `AppPageHeader`
### 1. Every page/module header is `OPageHeader`
**What.** The top of any routed view or module screen (title + icon + actions,
optionally tabs/breadcrumb/back) is rendered by `AppPageHeader`, never a
optionally tabs/breadcrumb/back) is rendered by `OPageHeader`, never a
hand-rolled `<div class="header">…<h1>…` or a bespoke `q-toolbar`.
```vue
<script setup lang="ts">
import AppPageHeader from "@/components/common/AppPageHeader.vue";
import OPageHeader from "@/lib/core/PageHeader/OPageHeader.vue";
import OButton from "@/lib/core/Button/OButton.vue";
</script>
<template>
<AppPageHeader
<OPageHeader
:title="t('dashboard.header')"
icon="dashboard"
:subtitle="t('dashboard.subtitle')"
@@ -31,11 +31,11 @@ import OButton from "@/lib/core/Button/OButton.vue";
{{ t("dashboard.add") }}
</OButton>
</template>
</AppPageHeader>
</OPageHeader>
</template>
```
**Why.** `AppPageHeader` encodes a single header contract used app-wide: row 1 is
**Why.** `OPageHeader` encodes a single header contract used app-wide: row 1 is
a fixed-height band (icon tile + `<h1>` + right-aligned actions); row 2 shows
**exactly one** of peer tabs, an ancestor breadcrumb, or a plain tagline. Every
hand-built header silently re-litigates title font size, icon tile geometry,
@@ -44,8 +44,8 @@ keeps the title's X/Y position identical as a user navigates list → detail →
edit, which is the whole point.
**How.**
- The component is `AppPageHeader`, at
`web/src/components/common/AppPageHeader.vue`. Its full API — props, slots, and
- The component is `OPageHeader`, at
`web/src/lib/core/PageHeader/OPageHeader.vue`. Its full API — props, slots, and
the one-row-content contract — is documented in this rule (below), so you can
use it correctly without opening the file.
- Props: `title`, `subtitle`, `icon` (an `IconName` from
@@ -56,20 +56,19 @@ edit, which is the whole point.
`back`.
- Put page actions in `#actions` using O2 components. Do not add your own
`border-b`, height, or padding around it — the header owns its own chrome.
- **Do not** style `AppPageHeader` from the outside with utility classes or a
- **Do not** style `OPageHeader` from the outside with utility classes or a
wrapper trying to change its internals. If it can't express what a page needs,
that's a change to `AppPageHeader` itself, not a per-page override.
that's a change to `OPageHeader` itself, not a per-page override.
### 2. Build from O2 components in `web/src/lib`
**What.** Compose UI out of the O2 library (`O*` components in
`web/src/lib/**`). Do not reach for a Quasar primitive (`q-btn`, `q-input`,
`q-select`, `q-dialog`, `q-table`, `q-tabs`, …) or a bare HTML control
(`<button>`, `<input>`, `<select>`, `<textarea>`, `<a>` used as a button) when an
O2 equivalent exists.
`web/src/lib/**`). Do not reach for a bare HTML control
(`<button>`, `<input>`, `<select>`, `<textarea>`, `<a>` used as a button) or an
unstyled third-party primitive when an O2 equivalent exists.
**Why.** O2 components have design decisions baked in — radius, color, focus
ring, dark-mode tokens, spacing, disabled/loading states. A raw `q-btn` or
ring, dark-mode tokens, spacing, disabled/loading states. A raw native
`<button>` re-introduces all of those as per-call-site choices, which is exactly
how a UI ends up with nine slightly different buttons. Baked-in design is the
feature, not a limitation.
@@ -92,11 +91,11 @@ feature, not a limitation.
design (see [§ Working with O2 components](conventions.md)).
- **If no O2 equivalent exists:** do NOT drop to a bare `<div>`/`<button>` to
fake it, and do NOT hand-assemble the element from utility classes. When
migrating, keep the existing Quasar component (or native element) in place and
migrating, keep the existing element in place and
flag that a new O2 component is needed. When building **new** UI, create a
reusable component instead — see
[§ No component fits? Build a reusable one](conventions.md).
An unstyled `div` is worse than an honest `q-btn`.
An unstyled `div` is worse than an honest native `<button>`.
### 3. No hardcoded `px`
@@ -126,15 +125,43 @@ utilities usually removes the temptation entirely.
(divide by 16: `320px → 20rem`, `22px → 1.375rem`, `6px → 0.375rem`).
- **The only accepted `px` is a `1px` hairline border/divider.** Every other `px`
— inline, in a `<style>` block, or in a class arbitrary value — is a smell.
- **Corner radius is a token, not a guess — exactly two tiers + circle:**
`rounded-default` (**4px** — controls: buttons, inputs, chips, small icon
buttons), `rounded-surface` (**12px** — surfaces: dialogs, drawers, cards,
panels, the app-shell content area), and `rounded-full` (pills / avatars /
status dots). Per-corner variants use the same names (`rounded-t-surface`,
`rounded-s-default`). **Banned:** bare `rounded`, arbitrary `rounded-[10px]`,
and the retired `rounded-{sm,md,lg,xl}` / `var(--radius-{sm,md,lg,xl})` — they
were five names for one value and were deleted (`sm`/`md` → `default`,
`lg`/`xl` → `surface`). Pick the tier by the element's role, never by eye.
### 4. No scoped CSS — style with bare Tailwind utilities
**What.** Do not add `<style scoped>` blocks. Style layout/spacing with **bare
Tailwind utility classes** (`flex flex-col gap-4 p-6`). **Two things are banned:**
Tailwind utility classes** (`flex flex-col gap-4 p-6`). **One thing is banned:**
- ❌ the **`tw:` prefix** — it was removed from this project; `tw:flex` no longer
resolves. Write `flex`, not `tw:flex`.
- ❌ **Quasar utilities** — `q-pa-md`, `q-gutter-lg`, `q-mb-sm`, `row`, `col`,
`text-weight-bold`, `items-center` as a Quasar class, etc. are NOT available.
In the rare case a `<style>` block genuinely cannot be Tailwind, it must **justify
itself** or CI counts it as debt (`styleKeepComment`, strict-ratcheted — a new
unjustified block fails the build). The block must open with a keep-comment:
```css
/* keep(lib-override:echarts): tooltip DOM is generated by the lib, no class hook */
```
Sanctioned tags: `lib-override:<lib>`, `generated-content`, `keyframes`, `print`,
`scrollbar`, `complex-state`, `brand`, `third-party` — one line stating *why this
cannot be utilities*. (`keep: <tag>` punctuation also accepted.)
**Comments count as debt too.** The design-consistency counters scan raw file
text — comments included. A `16px`, a `#fff`, or a `var(--color-*)` written in a
`<style>`-block comment counts against `stylePxUnit` / `styleBlockHex` /
`rawVarInComponent` exactly like real CSS, and the whole-file categories
(`rawPalette`, `tw:`, arbitrary `[..px]` values, retired aliases) match verbatim
mentions in template/JS comments as well. Write comment prose in `rem` or plain
words ("a 1rem gap", "the drawer's 0.75rem inset") — never quote a banned pattern
verbatim in a comment.
Prefer utility classes over inline `style=""`. Reserve inline `style` for the
rare dynamic value that must be computed in JS (and even then, prefer a bound
@@ -155,8 +182,10 @@ everything — they're the last resort, not the default.
This is the exact pattern real components use (e.g. `ModelPricingEditor.vue`,
`AddRegexPattern.vue`): `class="flex flex-col gap-4"`, `class="flex items-center gap-2"`.
- **Colors**: use token-backed utilities (`bg-surface-subtle`,
`text-text-secondary`, `border-border-default`) or, when a raw value is
unavoidable, the CSS custom property `var(--color-*)`. Never a hex/rgb literal.
`text-text-secondary`, `border-border-default`). A raw `var(--color-*)` inside a
`.vue` component is a **CI-counted bypass** (`rawVarInComponent`) — allowed only
in the sanctioned residue (`:deep`, `@keyframes`, `color-mix`, `calc`, SVG
`fill`/`stroke`, `v-html`). Never a hex/rgb literal.
- **Form field spacing**: put `class="flex flex-col gap-5"` (or `gap-6`) on the
`<OForm>` — `class`/`style` fall through to its root `<form>`, so its direct
children (the `OFormInput`/`OFormSelect` fields) get even vertical spacing.
@@ -164,9 +193,11 @@ everything — they're the last resort, not the default.
the #1 cause of "the dialog/drawer has no spacing".
- **Never use**:
- ❌ `tw:flex`, `tw:gap-2`, `tw:p-4` — the `tw:` prefix is dead; drop it → `flex gap-2 p-4`
- ❌ `q-pa-md`, `q-gutter-lg`, `row`, `col`, `text-weight-bold` (Quasar — not available)
- ❌ `#fff`, `rgb(...)` literals (use token utilities or `var(--color-*)`)
- ❌ `px` for sizing except `1px` borders (use the rem-based scale)
- ❌ literal font stacks — `font-family` (CSS or `[font-family:…]`/`font-[…]`
arbitrary values) must be `var(--font-sans)` or `var(--font-mono)`; anything
else is CI-banned (`literalFontFamily` + stylelint at error)
- **For O2 components**: pass only `variant` / `size` props — never patch
appearance with inline styles or ad-hoc classes.
@@ -178,15 +209,19 @@ tokens. If the token you need doesn't exist, **register it** in the token CSS
first, then use it.
**Why.** Tokens are what make the app theme-aware. A literal `#fff` is invisible
in dark mode and can't be retuned globally; a token (`text-text-primary`,
`bg-surface-base`, `var(--color-*)`) resolves to the right value in both themes
and changes everywhere at once when design updates it. Hardcoding a color is
opting a single element out of theming permanently.
in dark mode and can't be retuned globally; a token (`text-text-heading`,
`bg-surface-base`) resolves to the right value in both themes and changes
everywhere at once when design updates it. Hardcoding a color is opting a single
element out of theming permanently. **One knob per decision:** reuse an existing
token before minting one, and never add a second name for a value that already
has one — an alias is a decision made twice that silently splits adoption.
**How — using tokens.**
- Prefer **token-backed Tailwind utilities**: `text-text-primary`,
`bg-surface-base`, `bg-surface-subtle`, `border-border-default`,
`text-text-secondary`, `text-text-muted` — theme-aware by construction.
- Prefer **token-backed Tailwind utilities**: `text-text-heading` (titles),
`text-text-body`, `text-text-secondary`, `text-text-label`, `text-text-muted`,
`bg-surface-base`, `bg-surface-subtle`, `border-border-default` — theme-aware by
construction. (The old `text-text-primary` / `text-text-caption` are **retired
and CI-banned** — use `-heading` / `-secondary`.)
- **Don't write the arbitrary-value form when a utility exists** —
`bg-surface-base`, not `bg-[var(--color-surface-base)]` (they compile to
identical CSS; the utility name is the token minus `--color-`). Arbitrary
@@ -194,8 +229,11 @@ opting a single element out of theming permanently.
(a var-only domain token not in `@theme inline`, e.g.
`bg-[var(--color-card-glass-bg)]`) or a **load-bearing fallback**. Details +
the two exceptions in [references/design-tokens.md](design-tokens.md).
- When a raw variable is unavoidable, use the modern CSS custom property
`var(--color-*)` (e.g. in a bound `:style` for a computed value).
- When a raw variable is genuinely unavoidable (a `:deep()`/keyframe rule, a
`color-mix()`/`calc()`, an SVG `fill`/`stroke`, or `v-html`/JS-generated markup),
use the modern custom property `var(--color-*)` — but know it counts against the
`rawVarInComponent` ratchet. If it's just a colour you could name, register the
token and use its utility instead.
- **Only `--color-*` tokens** — see the `--o2-*` ban below.
**How — registering a NEW token / the `--o2-*` ban.** Full details, the token-file
@@ -223,7 +223,7 @@ Rule of thumb: **Tooltip = hint, Dropdown = action menu, Popover = anything free
### OTooltip
**Import:** `@/lib/overlay/Tooltip/OTooltip.vue`
**Use when:** A passive hover/focus hint describing an element — icon-button labels, truncated text, disabled-state explanations. Two modes: **wrapper** (default slot wraps the trigger) or **child** (no default slot — attaches to its parent element like `q-tooltip`).
**Use when:** A passive hover/focus hint describing an element — icon-button labels, truncated text, disabled-state explanations. Two modes: **wrapper** (default slot wraps the trigger) or **child** (no default slot — attaches to its parent element as its tooltip).
**Don't use for:** Anything the user interacts with inside the bubble (use **OPopover**); an action menu (use **ODropdown**).
**Key props:** `content` (string — plain-text shorthand for `#content`) · `side` (`"top"|"right"|"bottom"|"left"` — default `"top"`) · `align` (`"start"|"center"|"end"` — default `"center"`) · `sideOffset` (number — default `4`) · `alignOffset` (number — default `0`) · `delay` (number ms — default `700`) · `maxWidth` (string — default `"320px"`) · `disabled` (boolean) · `open` (boolean — controlled) · `contentClass` (string) · `shortcut` (`string | string[]`) · `shortcutId` (string — prefer over `shortcut`)
**Slots:** `default` (trigger — single focusable element; omit for child mode), `content` (rich content, overrides `content` prop)
@@ -7,7 +7,7 @@ feel like one product.
## Table of contents
- [Every page = AppPageHeader + body](#every-page--apppageheader--body)
- [Every page = OPageHeader + body](#every-page--opageheader--body)
- [Where a new page goes in navigation](#where-a-new-page-goes-in-navigation)
- [The listing-page skeleton (flush table, fixed height)](#the-listing-page-skeleton-flush-table-fixed-height)
- [Recipe: listing / table page](#recipe-listing--table-page)
@@ -20,9 +20,9 @@ feel like one product.
---
## Every page = AppPageHeader + body
## Every page = OPageHeader + body
Any routed view is: an `AppPageHeader` (rule 1) on top, then the page body below
Any routed view is: an `OPageHeader` (rule 1) on top, then the page body below
it, all inside a **full-height flex column** so the header stays put and only the
body scrolls.
@@ -33,7 +33,7 @@ body scrolls.
**do** give it horizontal padding and a bottom divider so the header aligns with
the app frame while the table below runs flush (see the skeleton next).
- The subtitle/description is the **`subtitle`** prop (there is **no**
`description` prop on `AppPageHeader`).
`description` prop on `OPageHeader`).
---
@@ -61,7 +61,7 @@ while the header aligns with the app frame:
<!-- page root: full height, NO page padding -->
<div class="flex flex-col h-full p-0">
<!-- header: shrink-0, its OWN horizontal padding + bottom divider -->
<AppPageHeader
<OPageHeader
:title="t('channels.title')"
icon="notifications"
:subtitle="t('channels.subtitle')"
@@ -72,7 +72,7 @@ while the header aligns with the app frame:
{{ t("channels.new") }}
</OButton>
</template>
</AppPageHeader>
</OPageHeader>
<!-- table wrapper: fills remaining height, scrolls internally, NO padding -->
<div class="card-container flex-1 min-h-0 overflow-hidden">
@@ -89,18 +89,22 @@ Why each class matters — these are load-bearing, not decoration:
chain: the header never scrolls, the table body does. `min-h-0` is what lets the
flex child shrink below its content so the inner scroll area works — omit it and
the whole page scrolls instead of the table.
- **Header `px-4` + `border-b border-border-default`**, table wrapper **no
horizontal padding** → the table is **flush** (rows touch the content-area
edges). Don't add page padding and don't wrap the table in a padded box — that
inset breaks the flush alignment. The comfortable first-column inset is provided
by `OTable` itself, not by page padding.
- **Header self-insets** (`OPageHeader` bakes in `px-page-edge`, the single
`--spacing-page-edge` grid line) **+ `border-b border-border-default`**, table
wrapper **no horizontal padding** → the table is **flush** (rows touch the
content-area edges) but its first-column inset (also `--spacing-page-edge`)
lands on the *same* grid line as the header title. **Never add a `px-*` to
`OPageHeader`** — it owns its inset; a consumer `px-4` would fight the baked
`px-page-edge` and knock the header 4px off the table. Don't add page padding and
don't wrap the table in a padded box — that inset breaks the flush alignment.
- **`card-container`** is the existing app class that gives the table area its
surface background; reuse it, don't invent a `bg-*`/border box (that would
double-frame the borderless table).
- **`:frame="false"`** on `OTable` keeps it borderless (its default) — the app
frame provides the single content card. If PageLayout wraps the view instead,
pass the same padding via its `header-class` prop
(`shrink-0 px-4 border-b border-border-default`).
frame provides the single content card. If OPageLayout wraps the view instead,
its `#header` wrapper is just `shrink-0 border-b border-border-default` — the
`OPageHeader` inside still supplies the `px-page-edge` inset, so don't re-add
it via `header-class`.
> Reserve `p-6`/`gap-6` page containers for **form / detail** views (constrained
> content), **not** for full-bleed listing tables.
@@ -109,7 +113,7 @@ Why each class matters — these are load-bearing, not decoration:
## Recipe: listing / table page
A listing page is `AppPageHeader` + an `OTable` that **always** carries three
A listing page is `OPageHeader` + an `OTable` that **always** carries three
toolbar affordances: a **search** box, a **refresh** button, and a
**column-visibility (show/hide columns) toggle**. These are the default standard
for every list — don't ship a listing page without them.
@@ -301,7 +305,7 @@ blocks:
A detail or create/edit screen that is the page's *primary* task (not a small
form — those go in an `ODialog`/`ODrawer`; see SKILL.md § Forms):
- `AppPageHeader` with a **back** target (`:back="{ label, to }"`) so the leading
- `OPageHeader` with a **back** target (`:back="{ label, to }"`) so the leading
icon tile becomes a Back button, and the title reflects the item.
- Body = the form, built with `OForm` + a colocated Zod schema (see
[forms-validation.md](forms-validation.md)). Save/Cancel follow the standard
@@ -314,10 +318,11 @@ form — those go in an `ODialog`/`ODrawer`; see SKILL.md § Forms):
## Listing-page checklist
- [ ] Full-height flex skeleton: root `flex flex-col h-full p-0`, header
`shrink-0 px-4 border-b border-border-default`, table wrapper
`shrink-0 border-b border-border-default` (OPageHeader self-insets with
`px-page-edge` — no `px-*` on it), table wrapper
`card-container flex-1 min-h-0 overflow-hidden` — **no page padding**, table
runs **flush**.
- [ ] `AppPageHeader` on top (description via **`subtitle`** prop); primary
- [ ] `OPageHeader` on top (description via **`subtitle`** prop); primary
**New** action in `#actions`.
- [ ] `OTable :frame="false"`; filters + **search** in `#toolbar`
(`:show-global-filter="false"`), or the built-in global filter for a
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+12
View File
@@ -54,6 +54,18 @@ jobs:
- name: Lint frontend code
run: cd web && npm run lint:ci
- name: Check for undefined CSS custom properties
run: cd web && npm run lint:tokens
- name: Check design-token consistency (strict ratchet — no raw-token creep, no slack)
run: cd web && npm run lint:design:strict
- name: Lint styles (stylelint — bans legacy --o2-* tokens)
run: cd web && npm run lint:styles
- name: Check token-file purity (token files contain tokens only)
run: cd web && npm run lint:token-purity
- name: Build frontend code
env:
NODE_OPTIONS: "--max-old-space-size=8192"
+12
View File
@@ -137,6 +137,18 @@ jobs:
- name: Lint frontend code
run: cd web && npm ci && npm run lint:ci
- name: Check for undefined CSS custom properties
run: cd web && npm run lint:tokens
- name: Lint styles (stylelint — bans legacy --o2-* tokens)
run: cd web && npm run lint:styles
- name: Check token-file purity (token files contain tokens only)
run: cd web && npm run lint:token-purity
- name: Check design-consistency ratchet (strict — hardcoded/arbitrary/raw-token, no slack)
run: cd web && npm run lint:design:strict
- name: Build binary and frontend
env:
NODE_OPTIONS: "--max-old-space-size=8192"
@@ -372,10 +372,6 @@ export class AlertCreationWizard {
}
await this.page.locator('body').click({ position: { x: 10, y: 10 } });
// Forcefully remove any remaining q-portal elements that intercept clicks
await this.page.evaluate(() => {
document.querySelectorAll('div[id^="q-portal"]').forEach(el => { el.style.display = 'none'; });
}).catch(e => testLogger.warn('Failed to remove q-portal elements', { error: e.message }));
await this.page.waitForTimeout(300);
// ==================== SUBMIT ====================
@@ -487,10 +483,9 @@ export class AlertCreationWizard {
await this.page.waitForTimeout(2000);
// Aggressively clean up any portal overlays that may intercept pointer events.
// Both the q-portal--dialog and reka-ui (data-reka-dialog-overlay) portals
// can linger after the dialog state is toggled off.
// The reka-ui (data-reka-dialog-overlay) portals can linger after the dialog
// state is toggled off.
await this.page.evaluate(() => {
document.querySelectorAll('div[id^="q-portal--dialog"]').forEach(el => { el.style.display = 'none'; });
document.querySelectorAll('div[data-reka-dialog-overlay]').forEach(el => { el.style.display = 'none'; });
document.querySelectorAll('div[data-reka-portalled]').forEach(el => { el.style.display = 'none'; });
}).catch(e => testLogger.warn('Failed to remove portal overlays', { error: e.message }));
@@ -1050,7 +1045,7 @@ export class AlertCreationWizard {
// v3 UI may have different toggle mechanisms than v2:
// 1. [data-test="alert-conditions-toggle-operator-btn"] (v2-style button)
// 2. Clickable AND chip/label between condition rows (v3-style chip)
// 3. Operator dropdown/select (v3-style q-select)
// 3. Operator dropdown/select (v3-style select)
let toggleSuccessful = false;
let message = '';
@@ -1572,7 +1567,7 @@ export class AlertCreationWizard {
// In measure mode, the "Alert if" row adds a field selector ("count of [field]").
// Must select a measure column or validateAndFocus() will reject with
// "Column is required when using an aggregate function."
// The field selector is the 2nd q-select in the "Alert if" row (after function dropdown)
// The field selector is the 2nd select in the "Alert if" row (after function dropdown)
const measureColumnSelect = alertIfSection.locator('.alert-v3-select').nth(1);
await measureColumnSelect.waitFor({ state: 'visible', timeout: 5000 });
await measureColumnSelect.click();
@@ -1595,9 +1590,9 @@ export class AlertCreationWizard {
}
// Select first available group-by field
// In v3, the Group By section begins with no q-select elements — the
// `v-for` loop over `logGroupBy` only renders q-selects when entries exist.
// Initially only the "+" add button (q-btn icon="add") is visible.
// In v3, the Group By section begins with no select elements — the
// `v-for` loop over `logGroupBy` only renders selects when entries exist.
// Initially only the "+" add button (add icon) is visible.
const groupBySection = this.page.locator('.alert-condition-row').filter({ hasText: 'Group by' }).first();
await groupBySection.waitFor({ state: 'visible', timeout: 5000 });
// Click the "+" add button to create a group-by field entry
@@ -1605,7 +1600,7 @@ export class AlertCreationWizard {
await addGroupByBtn.waitFor({ state: 'visible', timeout: 5000 });
await addGroupByBtn.click();
await this.page.waitForTimeout(800);
// Now the q-select should be rendered by the v-for — select from it
// Now the select should be rendered by the v-for — select from it
const groupBySelect = groupBySection.locator('.alert-v3-select').first();
await groupBySelect.waitFor({ state: 'visible', timeout: 5000 });
await groupBySelect.click();
@@ -1619,10 +1614,10 @@ export class AlertCreationWizard {
// Configure aggregation threshold (v3 UI)
// i18n key "alerts.queryConfig.havingGroups" renders as "Having groups"
// In v3, "Having groups" section only has:
// 1st q-select: operator (triggerOperator) with options ["=", "!=", ">=", ">", "<=", "<"]
// q-input: threshold value
// 1st select: operator (triggerOperator) with options ["=", "!=", ">=", ">", "<=", "<"]
// input: threshold value
const aggThresholdSection = this.page.locator('.alert-condition-row').filter({ hasText: 'Having groups' }).first();
// Operator q-select (first/only q-select in this section)
// Operator select (first/only select in this section)
const aggOperatorSelect = aggThresholdSection.locator('.alert-v3-select').first();
await aggOperatorSelect.waitFor({ state: 'visible', timeout: 5000 });
await aggOperatorSelect.click();
@@ -1844,7 +1839,7 @@ export class AlertCreationWizard {
await this.page.waitForTimeout(300);
testLogger.info('Set PromQL condition operator', { operator });
// Set value (q-input has debounce="300")
// Set value (input has debounce="300")
const promqlValueInput = promqlConditionRow.locator('input[type="number"]');
await promqlValueInput.clear();
await promqlValueInput.fill(String(conditionValue));
@@ -143,11 +143,6 @@ export class AlertDestinationsPage {
async navigateToDestinations(retryCount = 0) {
const maxRetries = 2;
// Clean up any q-portal elements that may intercept clicks
await this.page.evaluate(() => {
document.querySelectorAll('div[id^="q-portal"]').forEach(el => { if (el.getAttribute('aria-hidden') === 'true') el.style.display = 'none'; });
}).catch(() => {});
try {
await this.page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
await this.page.waitForTimeout(1000);
@@ -871,18 +866,13 @@ export class AlertDestinationsPage {
* @param {string} destinationName - Name for the destination
*/
async importDestinationFromFile(filePath, templateName, destinationName) {
// Clean up any q-portal elements that may intercept clicks
await this.page.evaluate(() => {
document.querySelectorAll('div[id^="q-portal"]').forEach(el => { if (el.getAttribute('aria-hidden') === 'true') el.style.display = 'none'; });
}).catch(() => {});
// Try multiple fallback selectors for the import button
const importBtnFallbackLocators = [
this.destinationImportButton,
'button:has-text("Import Destination")',
'[data-test*="destination-import"]',
'button:has-text("Import")',
'.q-table__control button:has-text("Import")',
'table button:has-text("Import")',
'button[data-o2-btn]:has-text("Import")'
];
@@ -1339,7 +1329,7 @@ export class AlertDestinationsPage {
*/
async expectStep1Visible() {
// Step 1 surfaces as the prebuilt destination selector being mounted/visible — the legacy
// q-stepper class has been removed in the UX revamp, so anchor on the selector data-test.
// stepper class has been removed in the UX revamp, so anchor on the selector data-test.
await expect(this.page.locator(this.prebuiltDestinationSelector)).toBeVisible({ timeout: 10000 });
testLogger.debug('Step 1 (destination selector) visible');
}
@@ -511,7 +511,7 @@ export class AlertManagement {
/**
* Open alert details dialog by clicking alert name in the list (PR #10470)
* The new AlertHistoryDrawer is rendered inside a q-dialog
* The new AlertHistoryDrawer is rendered inside a dialog
* @param {string} alertName - Name of the alert
*/
async openAlertDetailsDialog(alertName) {
@@ -67,11 +67,6 @@ export class AlertTemplatesPage {
async navigateToTemplates(retryCount = 0) {
const maxRetries = 2; // Maximum number of retry attempts
// Clean up any q-portal elements that may intercept clicks
await this.page.evaluate(() => {
document.querySelectorAll('div[id^="q-portal"]').forEach(el => { if (el.getAttribute('aria-hidden') === 'true') el.style.display = 'none'; });
}).catch(() => {});
try {
await this.page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
await this.page.waitForTimeout(1000);
@@ -169,20 +164,15 @@ export class AlertTemplatesPage {
await this.navigateToTemplates();
await this.page.waitForTimeout(2000);
// Clean up any q-portal elements that may intercept clicks
await this.page.evaluate(() => {
document.querySelectorAll('div[id^="q-portal"]').forEach(el => { if (el.getAttribute('aria-hidden') === 'true') el.style.display = 'none'; });
}).catch(() => {});
// Try multiple fallback selectors for add button
const addBtnLocator = this.page.locator(this.addTemplateButton);
const addBtnFallbackLocators = [
this.addTemplateButton,
'button:has-text("Add Template")',
'.q-table__control button',
'table button',
'button[data-test*="add"]',
'button:has(.OIcon):has-text("add")',
'.q-toolbar button:has-text("Add")',
'[role="toolbar"] button:has-text("Add")',
'button[data-o2-btn]:has-text("Add Template")'
];
@@ -250,7 +240,7 @@ export class AlertTemplatesPage {
// Strategy 2: data-test selector for template search
() => this.page.locator('[data-test="alert-template-search-input"]'),
// Strategy 3: input inside the templates section (look for search/filter input)
() => this.page.locator('[data-o2-page-container] .q-table__control input[type="text"], [data-o2-page-container] input.q-field__input[placeholder*="Search"], [data-o2-page-container] input[placeholder*="search"]').first()
() => this.page.locator('[data-o2-page-container] input[placeholder*="search"]').first()
];
for (const strategy of strategies) {
@@ -515,11 +505,6 @@ export class AlertTemplatesPage {
await this.navigateToTemplates();
await this.page.waitForTimeout(2000);
// Clean up any q-portal elements that may intercept clicks
await this.page.evaluate(() => {
document.querySelectorAll('div[id^="q-portal"]').forEach(el => { if (el.getAttribute('aria-hidden') === 'true') el.style.display = 'none'; });
}).catch(() => {});
await this.page.locator(this.addTemplateButton).click({ force: true });
await this.page.waitForTimeout(2000);
@@ -774,18 +759,13 @@ export class AlertTemplatesPage {
await this.navigateToTemplates();
await this.page.waitForTimeout(2000);
// Clean up any q-portal elements that may intercept clicks
await this.page.evaluate(() => {
document.querySelectorAll('div[id^="q-portal"]').forEach(el => { if (el.getAttribute('aria-hidden') === 'true') el.style.display = 'none'; });
}).catch(() => {});
// Try import button selectors (avoiding overly broad selectors that click wrong elements)
const importBtnFallbackLocators = [
this.templateImportButton,
'button:has-text("Import Template")',
'[data-test*="template-import"]',
'button:has-text("Import")',
'.q-table__control button:has-text("Import")',
'table button:has-text("Import")',
];
let importBtnClicked = false;
@@ -1232,7 +1212,7 @@ export class AlertTemplatesPage {
}
async expectValidationErrorVisible(message) {
// OInput shows error in .q-field__messages or .q-field__bottom
// OInput shows the error message text below the field
await expect(this.page.getByText(message)).toBeVisible({ timeout: 5000 });
}
@@ -1260,7 +1240,7 @@ export class AlertTemplatesPage {
}
// Try paginating through pages to find prebuilt badges
for (let pageNum = 0; pageNum < 10; pageNum++) {
const nextBtn = this.page.locator('button.q-pagination button[aria-label*="Next"], button:has-text("chevron_right")').first();
const nextBtn = this.page.locator('button:has-text("chevron_right")').first();
if (await nextBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
const isDisabled = await nextBtn.isDisabled().catch(() => true);
if (isDisabled) break;
@@ -534,7 +534,7 @@ export class AlertsPage {
* Returns 'table' if history table is visible, 'empty' if empty state is shown
*/
async expectAlertDetailsHistorySectionVisible() {
// The history section shows either a q-table (when history exists) or an empty state
// The history section shows either a history table (when history exists) or an empty state
const table = this.page.locator(this.locators.alertDetailsHistoryTable);
const emptyState = this.page.locator('text=No history available').or(this.page.locator('.OIcon:has-text("history")'));
@@ -658,7 +658,7 @@ export class AlertsPage {
async expectNoChartError() {
const chartArea = this.page.locator(this.locators.alertPreviewChart);
// Assert no error-class banner is visible within the chart container
const errorBanner = chartArea.locator('[class*="error"], .q-banner--negative').first();
const errorBanner = chartArea.locator('[class*="error"]').first();
await expect(errorBanner).not.toBeVisible({ timeout: 5000 });
// Assert no error text pattern is visible anywhere in the chart area.
// Do NOT swallow — a "Schema error" / "No field named" banner must fail the test.
@@ -666,7 +666,7 @@ export class AlertsPage {
await expect(errorText).not.toBeAttached({ timeout: 3000 });
// Also check page-level error toasts/banners rendered outside the chart container
const pageError = this.page.locator('[role="alert"][class*="bg-negative"], .q-banner--negative').first();
const pageError = this.page.locator('[role="alert"][class*="bg-negative"]').first();
await expect(pageError).not.toBeVisible({ timeout: 5000 });
testLogger.info('No chart error detected');
@@ -678,7 +678,7 @@ export class AlertsPage {
*/
async getChartErrorMessage() {
const chartArea = this.page.locator(this.locators.alertPreviewChart);
const errorEl = chartArea.locator('[class*="error"], .q-banner--negative, [role="alert"][class*="bg-negative"]').first();
const errorEl = chartArea.locator('[class*="error"], [role="alert"][class*="bg-negative"]').first();
if (await errorEl.isVisible({ timeout: 2000 }).catch(() => false)) {
return await errorEl.textContent();
}
@@ -877,10 +877,6 @@ export class AlertsPage {
await this.commonActions.navigateToAlerts();
await this.page.waitForTimeout(1000);
// Clean up any q-portal overlays that may intercept clicks
await this.page.evaluate(() => {
document.querySelectorAll('div[id^="q-portal"]').forEach(el => { if (el.getAttribute('aria-hidden') === 'true') el.style.display = 'none'; });
}).catch(() => {});
await this.page.waitForTimeout(500);
await this.page.locator(this.locators.newFolderButton).click();
@@ -1146,12 +1142,12 @@ export class AlertsPage {
await this.page.waitForTimeout(500);
// v3 wizard may handle validation differently:
// A) Show inline q-field--error on missing fields
// A) Show inline field error on missing fields
// B) Show a toast/notification at the top of the form
// C) Submit successfully (if optional fields have defaults)
// Wait for any of these outcomes within the timeout window.
const errorFields = this.page.locator('.q-field--error');
const anyToast = this.page.locator('[role="alert"], .q-alert, .notifications');
const errorFields = this.page.locator('[aria-invalid="true"]');
const anyToast = this.page.locator('[role="alert"], .notifications');
const successMsg = this.page.locator('[data-test-variant="success"] [data-test="o-toast-message"]').filter({ hasText: this.locators.alertSuccessMessage });
const outcomes = await Promise.race([
@@ -1990,7 +1986,7 @@ export class AlertsPage {
* @returns {Promise<string>} e.g. "P1", "P2", "P3", "P4"
*/
async getSeverityBadgeText() {
// Match the q-badge that contains P1, P2, P3, or P4 text
// Match the badge that contains P1, P2, P3, or P4 text
const badge = this.page.locator(this.locators.severityBadge).filter({ hasText: /^.*P[1-4].*$/ });
await badge.first().waitFor({ state: 'visible', timeout: 10000 });
// Get just the span with the severity value (first span inside the inner div)
@@ -2053,7 +2049,7 @@ export class AlertsPage {
/**
* Verify the service graph tab content rendered.
* Tabs are v-if gated (no q-tab-panel wrapper). The serviceGraph tab
* Tabs are v-if gated (no tab-panel wrapper). The serviceGraph tab
* renders IncidentServiceGraph inside an absolute-positioned div.
* Checks for the .incident-service-graph container OR the detail page
* still being visible (confirming no crash).
@@ -2871,12 +2867,12 @@ export class AlertsPage {
}
/**
* Force-remove residual q-portal elements that occasionally intercept clicks
* Force-remove residual portal overlays that occasionally intercept clicks
* after closing the SQL editor dialog. Used in scheduled feature tests.
*/
async removeResidualPortals() {
await this.page.evaluate(() => {
document.querySelectorAll('div[id^="q-portal"]').forEach(el => el.remove());
document.querySelectorAll('div[data-reka-dialog-overlay], div[data-reka-portalled]').forEach(el => el.remove());
}).catch(() => {});
}
@@ -2894,7 +2890,7 @@ export class AlertsPage {
* @param {number} stepIndex - 0-based step index
*/
async clickStepIndicator(stepIndex) {
const indicators = this.page.locator('.alert-v3-steps .step-indicator, [data-test*="step-indicator"] .q-stepper__nav-item, .alert-v3-steps > div > div');
const indicators = this.page.locator('.alert-v3-steps .step-indicator, [data-test*="step-indicator"], .alert-v3-steps > div > div');
const target = indicators.nth(stepIndex);
await target.waitFor({ state: 'visible', timeout: 10000 });
await target.click({ force: true });
@@ -3145,11 +3141,6 @@ export class AlertsPage {
* Click the update button for a specific alert
*/
async clickAlertUpdateButton(alertName) {
// Clean up any q-portal overlays that may intercept clicks
await this.page.evaluate(() => {
document.querySelectorAll('div[id^="q-portal"]').forEach(el => { if (el.getAttribute('aria-hidden') === 'true') el.style.display = 'none'; });
}).catch(() => {});
// Search for the alert first
await this.searchAlert(alertName);
await this.page.waitForTimeout(1000);
@@ -3584,7 +3575,7 @@ export class AlertsPage {
/**
* Get autocomplete suggestions dropdown items
* Scoped to menu context to avoid matching all q-items on page
* Scoped to menu context to avoid matching all option items on page
* @returns {Locator}
*/
getAutocompleteSuggestions() {
@@ -3607,8 +3598,8 @@ export class AlertsPage {
* In v3, aggregation is controlled by selecting a measure function from a dropdown:
* - "total events" (default) = no aggregation, no group-by
* - "count", "avg", "sum", etc. = aggregation enabled, group-by appears
* This replaces the v2 q-toggle for aggregation.
* @returns {Locator} The function dropdown q-select element
* This replaces the v2 toggle for aggregation.
* @returns {Locator} The function dropdown select element
*/
getAggregationToggle() {
// The aggregation function OSelect has no data-test. Its trigger is the first
@@ -3639,7 +3630,7 @@ export class AlertsPage {
/**
* Enable aggregation by selecting 'count' from the function dropdown (v3 UI).
* This replaces the v2 behavior of clicking a q-toggle.
* This replaces the v2 behavior of clicking a toggle.
*/
async enableAggregation() {
const toggle = this.getAggregationToggle();
@@ -3651,7 +3642,7 @@ export class AlertsPage {
/**
* Disable aggregation by selecting 'total events' from the function dropdown (v3 UI).
* This replaces the v2 behavior of clicking a q-toggle off.
* This replaces the v2 behavior of clicking a toggle off.
*/
async disableAggregation() {
const toggle = this.getAggregationToggle();
@@ -3865,6 +3856,6 @@ export class AlertsPage {
* @returns {Locator}
*/
getErrorMessageBanner() {
return this.page.locator('[class*="error"], .q-banner--negative, [data-test*="error"]');
return this.page.locator('[class*="error"], [data-test*="error"]');
}
}
@@ -56,7 +56,7 @@ class AnomalyDetectionPage {
// Alerting
alertEnabled: '[data-test="anomaly-alert-enabled"]',
alertToggleLabel: '.q-toggle__label',
alertToggleLabel: '[role="switch"]',
destination: '[data-test="anomaly-destination"]',
destinationError: '[data-test="anomaly-destination-error"]',
refreshDestinationsBtn: '[data-test="anomaly-refresh-destinations"]',
@@ -67,7 +67,7 @@ class AnomalyDetectionPage {
cancelButton: '[data-test="add-alert-cancel-btn"]',
// Generic framework components
qToggle: '.q-toggle',
qToggle: '[role="switch"]',
qMenuItem: '[data-test$="-popover"] [data-test$="-option"]',
qMenu: '[data-test$="-popover"]',
qDialog: '[data-test$="-dialog"]',
@@ -357,7 +357,7 @@ class AnomalyDetectionPage {
const toggleSelectors = [
this.selectors.alertEnabled,
'[data-test="anomaly-alert-enabled"]',
'.q-toggle'
'[role="switch"]'
];
let toggle = null;
@@ -376,7 +376,7 @@ class AnomalyDetectionPage {
}
// Get toggle label to determine state
const toggleLabel = await toggle.locator('div.q-toggle__label').textContent().catch(() => '');
const toggleLabel = await toggle.locator('[role="switch"]').textContent().catch(() => '');
testLogger.info('Alert toggle state', { toggleLabel });
if (toggleLabel.toLowerCase().includes('enabled')) {
@@ -385,7 +385,7 @@ class AnomalyDetectionPage {
await this.page.waitForTimeout(1000);
// Verify it was disabled
const newLabel = await toggle.locator('div.q-toggle__label').textContent().catch(() => '');
const newLabel = await toggle.locator('[role="switch"]').textContent().catch(() => '');
testLogger.info('After clicking toggle', { toggleLabel: newLabel });
testLogger.info('Disabled alerting');
} else {
@@ -464,7 +464,7 @@ class AnomalyDetectionPage {
const filterCount = await filterRows.count();
const lastFilterRow = filterRows.nth(filterCount - 1);
// 1. Fill the field select (first q-select in the row)
// 1. Fill the field select (first select in the row)
const fieldSelect = lastFilterRow.locator('.filter-field-select, .alert-v3-select').first();
await expect(fieldSelect).toBeVisible({ timeout: 3000 });
await fieldSelect.click();
@@ -484,7 +484,7 @@ class AnomalyDetectionPage {
}
await this.page.waitForTimeout(500);
// 2. Fill the operator select (second q-select in the row)
// 2. Fill the operator select (second select in the row)
const operatorSelect = lastFilterRow.locator('.alert-v3-select').nth(1);
if (await operatorSelect.isVisible({ timeout: 2000 }).catch(() => false)) {
await operatorSelect.click();
@@ -499,7 +499,7 @@ class AnomalyDetectionPage {
await this.page.waitForTimeout(500);
}
// 3. Fill the value input (q-input in the row)
// 3. Fill the value input (input in the row)
const valueInput = lastFilterRow.locator('.alert-v3-input input, input').first();
if (await valueInput.isVisible({ timeout: 2000 }).catch(() => false)) {
await valueInput.click();
+1 -1
View File
@@ -138,7 +138,7 @@ export class CommonActions {
*/
async scrollAndFindOption(optionName, optionType) {
// Target the visible dropdown — OSelect (Reka Listbox) post-migration,
// q-select (.q-menu) pre-migration.
// legacy select pre-migration.
const dropdown = this.page.locator('[data-test$="-popover"]').first();
// Fast path for the migrated OSelect: it renders a ListboxFilter search input
@@ -88,7 +88,7 @@ export async function deleteDashboard(page, dashboardName) {
const deleteButton = dashboardRow.locator('[data-test="dashboard-delete"]');
await deleteButton.click();
// Wait for the confirm button to be visible (q-dialog teleports content to body; text element
// Wait for the confirm button to be visible (dialog teleports content to body; text element
// may be in DOM but hidden during animation — confirm button visibility is more reliable)
await page.locator('[data-test="o-dialog-primary-btn"]').waitFor({
state: 'visible',
@@ -91,7 +91,7 @@ export default class DashboardImport {
// Tab might already be active, continue
}
// The q-file renders the underlying input as hidden — wait for
// The file control renders the underlying input as hidden — wait for
// `attached` (not visible) since setInputFiles works on hidden inputs.
await this.inputFile.waitFor({ state: "attached", timeout: 20000 });
@@ -74,7 +74,7 @@ export default class DashboardMultiSQL {
* Locator for the trellis-chart OSelect when it is disabled.
* OSelect renders an inner trigger button that carries the same
* `data-test` as the wrapper, plus the standard `disabled` attribute
* when the prop is set. The `.q-field--disabled` class no longer exists
* when the prop is set. The legacy disabled class no longer exists
* after the v1 UX revamp.
*/
trellisDisabled() {
@@ -579,17 +579,15 @@ export default class DashboardPanelConfigs {
/**
* Fill the threshold for the conditional rule at ruleIdx (default operator "<").
* The threshold OInput has no data-test; scoped via the rule row's `.w-28` container,
* located by walking up from the ruleIdx's text-color swatch (which does have a data-test).
* The threshold OInput carries `o2-format-cond-threshold-<field>-<ruleIdx>`, which
* OInput forwards to its inner <input> as `...-field`.
*/
async fillConditionThreshold(ruleIdx, threshold) {
const anchor = this.overrideDialog
.locator(`[data-test^="o2-format-cond-text-"][data-test$="-${ruleIdx}"]`)
const thresholdInput = this.overrideDialog
.locator(
`[data-test^="o2-format-cond-threshold-"][data-test$="-${ruleIdx}-field"]`
)
.first();
const row = anchor.locator(
'xpath=ancestor::div[contains(@class,"rounded-md")][1]'
);
const thresholdInput = row.locator(".w-28 input").first();
await thresholdInput.waitFor({ state: "visible", timeout: 5000 });
await thresholdInput.fill(String(threshold));
}
@@ -29,7 +29,7 @@ export default class DashboardTimeRefresh {
async setRelative(date, time) {
// Wait until the trigger is visible (not just attached). attached only
// checks DOM presence, but in CI the button can be in the DOM before its
// q-menu is ready to open — clicking too early opens nothing and the
// menu is ready to open — clicking too early opens nothing and the
// subsequent relativeTime.click() waits forever.
await this.timeTab.waitFor({ state: "visible" });
@@ -20,26 +20,26 @@ const COMPONENTS = {
// overlay intercepts pointer events on the dashboard, so missing the new selector
// here causes clicks on variable dropdowns to time out.
DIALOG: '[data-test="dashboard-settings-drawer"], [data-o2-dialog], [data-o2-drawer]',
DIALOG_CARD: '[data-test="dashboard-settings-drawer"] .q-card',
DIALOG_CARD: '[data-test="dashboard-settings-drawer"]',
// Menu/Dropdown components
MENU: '[data-test$="-popover"]',
MENU_ITEM: '[data-test$="-option"]',
// Form components
CHECKBOX: '.q-checkbox',
CHECKBOX_CHECKED: '.q-checkbox[aria-checked="true"]',
CHECKBOX_UNCHECKED: '.q-checkbox[aria-checked="false"]',
CHECKBOX: '[role="checkbox"]',
CHECKBOX_CHECKED: '[role="checkbox"][aria-checked="true"]',
CHECKBOX_UNCHECKED: '[role="checkbox"][aria-checked="false"]',
// Loading indicators (legacy — kept for backward compat; prefer specific data-test selectors)
SPINNER: '[data-test*="loading-indicator"]',
LINEAR_PROGRESS: '[data-test*="loading-indicator"]',
// Chip/Badge components — OBadge replaces q-chip; use data-test or aria where available
// Chip/Badge components — OBadge replaces the legacy chip; use data-test or aria where available
CHIP: '[data-test*="chip"], [data-test*="badge"]',
// Tooltip
TOOLTIP: '.q-tooltip',
TOOLTIP: '[role="tooltip"]',
};
/**
@@ -209,7 +209,7 @@ function getPanelRefreshBtn(panelId) {
/**
* Get menu item by text (case-insensitive)
* Uses filter pattern: .q-item filter hasText
* Uses filter pattern: option filter hasText
* @param {string} text - Item text
* @param {boolean} exact - Use exact match (default: true)
* @returns {Object} Object with selector and filter pattern
@@ -235,7 +235,7 @@ export default class DashboardSetting {
await generalTab.waitFor({ state: "visible", timeout: 10000 });
await this.page.waitForLoadState('domcontentloaded').catch(() => {});
// Wait for dialog tabs to be fully loaded - the tabs are in a q-tabs container
// Wait for dialog tabs to be fully loaded - the tabs are in a tabs container
await this.page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
// Retry pattern for clicking variables tab (element can get detached during dialog transitions)
@@ -1306,7 +1306,7 @@ export default class DashboardVariablesScoped {
const isDefault = typeof value === 'object' && value.selected === true;
// If this is not the first item, add a new option and wait for it to appear.
// The q-field__bottom validation div permanently intercepts pointer events over
// The field's bottom validation div permanently intercepts pointer events over
// the button, blocking both regular and force clicks. Use evaluate() to call the DOM
// native click() which fires the full event sequence and bypasses CSS pointer-events.
if (i > 0) {
@@ -281,7 +281,7 @@ export class DashboardPage {
// input it is, so Enter triggers the form's implicit submit → onSave →
// savePanelChangesToDashboard, which saves the panel and navigates away,
// detaching the Apply button mid-flow (see reports E2E timeouts on
// [data-test="dashboard-apply"]). The legacy q-input ignored Enter;
// [data-test="dashboard-apply"]). The legacy input ignored Enter;
// .fill() already commits the title through OFormInput's reactive binding,
// so no key press is needed to "commit" the name.
await expect(this.applyButton).toBeVisible();
@@ -404,8 +404,8 @@ export class CorrelationSettingsPage {
}
// Last resort - check for checked class
const hasCheckedClass = await checkbox.evaluate(el => {
return el.classList.contains('q-checkbox--checked') ||
el.closest('.q-checkbox')?.classList.contains('q-checkbox--checked');
return el.classList.contains('checked') ||
el.closest('[role="checkbox"]')?.getAttribute('aria-checked') === 'true';
}).catch(() => false);
return hasCheckedClass;
}
@@ -539,7 +539,7 @@ export class CorrelationSettingsPage {
async getAvailableDimensionsCount() {
// Count items in the available dimensions list (right side)
const list = this.page.locator('.q-list').nth(1); // TODO: verify list container selector post-Reka migration
const list = this.page.locator('[role="listbox"]').nth(1); // TODO: verify list container selector post-Reka migration
await list.waitFor({ state: 'visible', timeout: 10000 });
const items = list.locator('[role="option"], [data-test$="-item"]'); // TODO: verify data-test for dimension list items
return await items.count();
@@ -586,7 +586,7 @@ export class CorrelationSettingsPage {
}
// Fallback: check for checked class
const classList = await checkbox.getAttribute('class');
return classList?.includes('q-checkbox--checked') || classList?.includes('checked');
return classList?.includes('checked');
}
// ==================== Alert Correlation Save Button ====================
@@ -613,11 +613,11 @@ export class CorrelationSettingsPage {
* @param {string} dimensionName - Name of dimension to select
*/
async selectAvailableDimension(dimensionName) {
// Available dimensions are in the second q-list (right side) within service-identity-config
// Available dimensions are in the second list (right side) within service-identity-config
const serviceConfig = this.page.locator('.service-identity-config');
await serviceConfig.waitFor({ state: 'visible', timeout: 10000 });
// Lists have q-list--bordered class (from the bordered prop) and h-80 height class
const lists = serviceConfig.locator('.q-list--bordered, .q-list.tw\\:h-80');
// Bordered lists (from the bordered prop) with the h-80 height class
const lists = serviceConfig.locator('[role="listbox"]');
const rightList = lists.last();
await rightList.waitFor({ state: 'visible', timeout: 5000 });
const item = rightList.locator('[role="option"], [data-test$="-item"]').filter({ hasText: dimensionName }).first(); // TODO: verify data-test
@@ -631,11 +631,11 @@ export class CorrelationSettingsPage {
* @param {string} dimensionName - Name of dimension to select
*/
async selectPriorityDimension(dimensionName) {
// Priority dimensions are in the first q-list (left side) within service-identity-config
// Priority dimensions are in the first list (left side) within service-identity-config
const serviceConfig = this.page.locator('.service-identity-config');
await serviceConfig.waitFor({ state: 'visible', timeout: 10000 });
// Lists have q-list--bordered class (from the bordered prop) and h-80 height class
const lists = serviceConfig.locator('.q-list--bordered, .q-list.tw\\:h-80');
// Bordered lists (from the bordered prop) with the h-80 height class
const lists = serviceConfig.locator('[role="listbox"]');
const leftList = lists.first();
await leftList.waitFor({ state: 'visible', timeout: 5000 });
const item = leftList.locator('[role="option"], [data-test$="-item"]').filter({ hasText: dimensionName }).first(); // TODO: verify data-test
@@ -651,8 +651,8 @@ export class CorrelationSettingsPage {
async getPriorityDimensionsCount() {
const serviceConfig = this.page.locator('.service-identity-config');
await serviceConfig.waitFor({ state: 'visible', timeout: 10000 });
// Lists have q-list--bordered class (from the bordered prop) and h-80 height class
const lists = serviceConfig.locator('.q-list--bordered, .q-list.tw\\:h-80');
// Bordered lists (from the bordered prop) with the h-80 height class
const lists = serviceConfig.locator('[role="listbox"]');
const leftList = lists.first();
await leftList.waitFor({ state: 'visible', timeout: 5000 });
// Exclude empty state items (the "No dimensions configured" message)
@@ -666,8 +666,8 @@ export class CorrelationSettingsPage {
*/
async expectPriorityDimensionExists(dimensionName) {
const serviceConfig = this.page.locator('.service-identity-config');
// Lists have q-list--bordered class (from the bordered prop) and h-80 height class
const lists = serviceConfig.locator('.q-list--bordered, .q-list.tw\\:h-80');
// Bordered lists (from the bordered prop) with the h-80 height class
const lists = serviceConfig.locator('[role="listbox"]');
const leftList = lists.first();
const item = leftList.locator('[role="option"], [data-test$="-item"]').filter({ hasText: dimensionName }).first(); // TODO: verify data-test
await expect(item).toBeVisible({ timeout: 5000 });
@@ -679,8 +679,8 @@ export class CorrelationSettingsPage {
*/
async expectPriorityDimensionNotExists(dimensionName) {
const serviceConfig = this.page.locator('.service-identity-config');
// Lists have q-list--bordered class (from the bordered prop) and h-80 height class
const lists = serviceConfig.locator('.q-list--bordered, .q-list.tw\\:h-80');
// Bordered lists (from the bordered prop) with the h-80 height class
const lists = serviceConfig.locator('[role="listbox"]');
const leftList = lists.first();
const item = leftList.locator('[role="option"], [data-test$="-item"]').filter({ hasText: dimensionName }).first(); // TODO: verify data-test
await expect(item).not.toBeVisible({ timeout: 5000 });
@@ -734,7 +734,7 @@ export class CorrelationSettingsPage {
return isChecked === 'true';
}
const classList = await checkbox.getAttribute('class');
return classList?.includes('q-checkbox--checked') || classList?.includes('checked');
return classList?.includes('checked');
}
/**
@@ -773,7 +773,7 @@ export class CorrelationSettingsPage {
async expectServicesDataLoaded() {
await this.page.locator(this.loadingSpinner).waitFor({ state: 'hidden', timeout: 30000 }).catch(() => {});
// Check for either services content or empty state
const hasContent = await this.page.locator('.q-table, .q-list, .discovered-services-content').first().isVisible().catch(() => false);
const hasContent = await this.page.locator('table, [role="listbox"], .discovered-services-content').first().isVisible().catch(() => false);
const hasEmptyState = await this.page.locator('text=/no.*service|empty/i').first().isVisible().catch(() => false);
return hasContent || hasEmptyState;
}
@@ -857,8 +857,8 @@ export class CorrelationSettingsPage {
async getFirstAvailableDimensionName() {
const serviceConfig = this.page.locator('.service-identity-config');
await serviceConfig.waitFor({ state: 'visible', timeout: 10000 });
// Lists have q-list--bordered class (from the bordered prop) and h-80 height class
const lists = serviceConfig.locator('.q-list--bordered, .q-list.tw\\:h-80');
// Bordered lists (from the bordered prop) with the h-80 height class
const lists = serviceConfig.locator('[role="listbox"]');
const rightList = lists.last();
await rightList.waitFor({ state: 'visible', timeout: 5000 });
const firstItem = rightList.locator('[role="option"]:not(:has-text("No")), [data-test$="-item"]:not(:has-text("No"))').first(); // TODO: verify data-test
@@ -881,8 +881,8 @@ export class CorrelationSettingsPage {
async getFirstPriorityDimensionName() {
const serviceConfig = this.page.locator('.service-identity-config');
await serviceConfig.waitFor({ state: 'visible', timeout: 10000 });
// Lists have q-list--bordered class (from the bordered prop) and h-80 height class
const lists = serviceConfig.locator('.q-list--bordered, .q-list.tw\\:h-80');
// Bordered lists (from the bordered prop) with the h-80 height class
const lists = serviceConfig.locator('[role="listbox"]');
const leftList = lists.first();
await leftList.waitFor({ state: 'visible', timeout: 5000 });
const firstItem = leftList.locator('[role="option"]:not(:has-text("No")), [data-test$="-item"]:not(:has-text("No"))').first(); // TODO: verify data-test
@@ -1018,7 +1018,7 @@ abc, err = get_enrichment_table_record("${fileName}", {
}
async verifySchemaModalVisible() {
// EnrichmentSchema migrated from q-dialog to ODrawer
// EnrichmentSchema migrated from the legacy dialog to ODrawer
await this.schemaDrawer.waitFor({ state: 'visible', timeout: 15000 });
}
@@ -1067,7 +1067,7 @@ abc, err = get_enrichment_table_record("${fileName}", {
}
async verifyDeleteConfirmationDialog() {
// ConfirmDialog migrated from q-dialog to ODialog — scope to the new panel
// ConfirmDialog migrated from the legacy dialog to ODialog — scope to the new panel
await this.confirmDialog.waitFor({ state: 'visible', timeout: 10000 });
// The dialog primary/secondary buttons render once the dialog is open.
await this.confirmDialogOk.waitFor({ state: 'visible', timeout: 5000 });
@@ -1656,7 +1656,7 @@ abc, err = get_enrichment_table_record("${fileName}", {
/**
* Close any open dialogs/modals.
*
* The logs row-detail panel was migrated from q-dialog to an ODrawer
* The logs row-detail panel was migrated from the legacy dialog to an ODrawer
* (SearchResult.vue → `logs-search-result-detail-dialog`). Reka UI's
* escape-key-down only fires when focus is inside the drawer, but
* ODrawer's handleOpenAutoFocus prevents the default focus move and the
@@ -2135,7 +2135,7 @@ abc, err = get_enrichment_table_record("${fileName}", {
async closeUrlJobsDialog() {
testLogger.debug('Closing URL Jobs dialog');
// URL Jobs migrated from q-dialog to ODrawer — use the o-drawer close button
// URL Jobs migrated from the legacy dialog to ODrawer — use the o-drawer close button
const closeBtn = this.urlJobsDrawerCloseBtn.first();
if (await closeBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await closeBtn.click();
@@ -361,16 +361,16 @@ export class HomePage {
* After Header.vue migration the user profile menu is an ODropdown
* ([role="menuitem"]) but the language sub-menu was kept in-place (not
* supported by ODropdown nesting). The data-test attribute is preserved
* either on a q-item-section or directly on a menuitem (ODropdown),
* either on a menu item section or directly on a menuitem (ODropdown),
* so target the element with data-test and walk up to its clickable ancestor.
* @param {string} langCode - Language code (e.g., 'en-us', 'de', 'es', 'fr', etc.)
* @returns {Locator} - Playwright locator for the language option
*/
getLanguageOption(langCode) {
// Try ODropdown menuitem first (post-migration), fall back to q-item ancestor.
// ODropdown menuitem ancestor.
return this.page
.locator(`[data-test="language-dropdown-item-${langCode}"]`)
.locator('xpath=ancestor-or-self::*[@role="menuitem" or contains(@class, "q-item")][1]')
.locator('xpath=ancestor-or-self::*[@role="menuitem"][1]')
.first();
}
@@ -410,7 +410,7 @@ export class HomePage {
async changeLanguage(langCode) {
await this.openLanguageMenu();
// Click directly on the element with data-test, or its parent q-item
// Click directly on the element with data-test, or its parent menu item
const langOption = this.page.locator(`[data-test="language-dropdown-item-${langCode}"]`);
await langOption.waitFor({ state: 'visible', timeout: 5000 });
@@ -49,7 +49,7 @@ export class SanityPage {
// Saved View / Function dialog locators
// ============================================================
// Saved view dialog (SearchBar.vue:1654) and saved function dialog (SearchBar.vue:1703) were migrated
// from q-dialog to ODialog. The legacy shared data-test "saved-view-dialog-save-btn" no longer exists;
// from the legacy dialog to ODialog. The legacy shared data-test "saved-view-dialog-save-btn" no longer exists;
// each dialog now exposes its own ODialog primary button. The dialogs are mutually exclusive at runtime.
this.savedViewDialogSave = page.locator('[data-test="search-bar-store-state-saved-view-dialog"] [data-test="o-dialog-primary-btn"], [data-test="search-bar-store-state-saved-function-dialog"] [data-test="o-dialog-primary-btn"]');
// After ConfirmDialog migrated to ODialog, the OK button no longer
@@ -730,7 +730,7 @@ export class SanityPage {
await this.page.reload();
await this.page.waitForLoadState('domcontentloaded');
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
// Click the first stream row indicator (was the "01" cell in the legacy q-table).
// Click the first stream row indicator (was the "01" cell in the legacy table).
await this.firstStreamRow.waitFor({ state: 'visible', timeout: 15000 });
await this.firstStreamRow.click();
}
@@ -139,7 +139,7 @@ class SchemaPage {
// Stream creation locators
logStreamAddStreamBtn: '[data-test="log-stream-add-stream-btn"]',
nameLabel: 'Name *',
// AddStream.vue migrated from q-btn to ODrawer's primary footer
// AddStream.vue migrated from the legacy button to ODrawer's primary footer
// button — there is no standalone "save-stream-btn" any more.
saveStreamBtn: '[data-test="add-stream-dialog"] [data-test="o-dialog-primary-btn"]',
menuHomeItem: '[data-test="menu-link-\\/-item"]',
@@ -269,7 +269,7 @@ class SchemaPage {
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
// Try to find and click kubernetes checkboxes (they may not always exist).
// These names are legacy from the q-table schema; if the corresponding
// These names are legacy from the old table schema; if the corresponding
// data-tests no longer exist after the OTable migration the row checkboxes
// are simply not found and the try-block swallows the timeout.
try {
@@ -48,8 +48,10 @@ export class ThemePage {
// Theme Switcher
this.themeToggleBtn = page.locator('[data-test="navbar-theme-toggle-btn"]');
// Body class for theme detection (the dark plugin adds 'body--dark')
this.bodyDarkClass = 'body--dark';
// Dark-mode detection. The dark-mode signal is the `.dark` class on
// <html> (document.documentElement) — set by utils/theme.ts. The legacy
// `body--dark` class on <body> was retired in the token migration.
this.bodyDarkClass = 'dark';
// Notification — OToast surfaces success/error toasts under stable data-test slugs
this.successToast = page.locator('[data-test-variant="success"]');
@@ -133,7 +135,7 @@ export class ThemePage {
const bodyDarkClass = this.bodyDarkClass;
await this.page.waitForFunction(
([darkClass, expectedDark]) => {
const isDark = document.body.classList.contains(darkClass);
const isDark = document.documentElement.classList.contains(darkClass);
return isDark !== expectedDark;
},
[bodyDarkClass, wasDark],
@@ -143,7 +145,7 @@ export class ThemePage {
async isDarkMode() {
const bodyDarkClass = this.bodyDarkClass;
return await this.page.evaluate((darkClass) => document.body.classList.contains(darkClass), bodyDarkClass);
return await this.page.evaluate((darkClass) => document.documentElement.classList.contains(darkClass), bodyDarkClass);
}
async isLightMode() {
@@ -334,7 +336,7 @@ export class ThemePage {
async getThemeColor() {
return await this.page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue('--q-primary').trim();
return getComputedStyle(document.documentElement).getPropertyValue('--color-accent').trim();
});
}
+2 -3
View File
@@ -212,9 +212,8 @@ export class IamPage {
async clickCopyToken() {
// ODialog renders via reka-ui's DialogPortal, not a
// #q-portal--dialog--N container, so scope by the token dialog's
// own data-test attribute instead.
// ODialog renders via reka-ui's DialogPortal, so scope by the token
// dialog's own data-test attribute instead.
await this.tokenCopyButton.click();
}
+12 -12
View File
@@ -91,8 +91,8 @@ export class LogsPage {
// OInput convention §4: drive the auto-derived `-field` inner native input for fill().
this.savedViewNameInput = '[data-test="add-alert-name-input-field"]';
// Saved view dialog (SearchBar.vue:1654) and saved function dialog (SearchBar.vue:1703) were both migrated
// from q-dialog to ODialog. Tests historically shared a single save-button selector because the legacy
// q-dialog used the same data-test on both. With ODialog each dialog has its own primary button, so the
// from the legacy dialog to ODialog. Tests historically shared a single save-button selector because the legacy
// dialog used the same data-test on both. With ODialog each dialog has its own primary button, so the
// selector matches whichever dialog is currently open (they are mutually exclusive).
this.savedViewDialogSave = '[data-test="search-bar-store-state-saved-view-dialog"] [data-test="o-dialog-primary-btn"], [data-test="search-bar-store-state-saved-function-dialog"] [data-test="o-dialog-primary-btn"]';
this.savedViewDialog = '[data-test="search-bar-store-state-saved-view-dialog"]';
@@ -186,7 +186,7 @@ export class LogsPage {
this.savedFunctionNameInput = '[data-test="saved-function-name-input"]';
// OInput convention (AGENT_RULES §4): inner native <input> carries `-field` suffix
this.savedFunctionNameInputField = '[data-test="saved-function-name-input-field"]';
this.qNotifyWarning = '#q-notify div';
this.qNotifyWarning = '[role="alert"]';
this.qPageContainer = '[data-test="logs-page-container"]';
this.cmContent = '.view-lines';
this.cmLine = '.view-line';
@@ -900,7 +900,7 @@ export class LogsPage {
// wrapper down to its single `button` child instead.
// - Option: [data-test="log-search-index-list-select-stream-option"]
// [data-test-value="<stream>"] (rendered into a portalled popover by OSelect).
// The legacy q-select `log-search-index-list-stream-toggle-*` data-test is no longer
// The legacy select `log-search-index-list-stream-toggle-*` data-test is no longer
// emitted, so we open the popover and pick the option directly. The retry loop
// re-opens the popover on miss to handle the case where the stream list streams in
// late after page navigation — matching the original 5-attempt retry semantic.
@@ -997,7 +997,7 @@ export class LogsPage {
async deselectStream(streamName) {
testLogger.info(`Deselecting stream: ${streamName}`);
// Legacy q-select used `log-search-index-list-stream-toggle-<name> div`;
// Legacy select used `log-search-index-list-stream-toggle-<name> div`;
// post-OSelect migration that data-test is gone. Pick the same option
// by `data-test-value` — toggling an already-selected option deselects
// it in OSelect's multi-mode (selectionBehavior=toggle).
@@ -1575,7 +1575,7 @@ export class LogsPage {
await quickMode.waitFor({ state: 'hidden', timeout: 2000 }).catch(() => {});
await this.page.locator(this.utilitiesMenuButton).click();
await quickMode.waitFor({ state: 'visible', timeout: 10000 });
// Click the q-item directly - it has @click="handleQuickMode" handler.
// Click the menu option directly - it has @click="handleQuickMode" handler.
// force:true bypasses the stability check; the popper portal animates so the
// element can be transiently re-laid-out, but the click is still committed.
await quickMode.click({ force: true });
@@ -1869,7 +1869,7 @@ export class LogsPage {
const resultsDropdown = this.page.locator(this.recordsPerPageDropdown);
await resultsDropdown.waitFor({ state: 'visible', timeout: 15000 }).catch(() => {});
await resultsDropdown.click({ force: true });
// Records-per-page is OSelect (Reka Listbox) post-migration, q-select pre.
// Records-per-page is OSelect (Reka Listbox) post-migration, the legacy select pre.
await this.page.waitForTimeout(500);
// Target option by data-test-value (OSelect emits `${parent}-option` + data-test-value).
const option10 = this.page.locator(this.recordsPerPageOption(10)).first();
@@ -3124,14 +3124,14 @@ export class LogsPage {
// This method clicks the search input inside the saved views dialog
//
// Why this needs special handling:
// The search input is inside a q-table's #top template slot. When the table's
// The search input is inside the table's #top template slot. When the table's
// data updates (e.g., after applying a saved view), the entire #top template
// gets re-rendered, causing the input element to be detached and recreated.
//
// The problem was exacerbated by:
// 1. debounce="1" (now changed to 300) - caused rapid re-renders
// 2. Table data updating from ongoing searches
// 3. Invalid HTML structure (q-tr inside #top-right - now fixed)
// 3. Invalid HTML structure (a table row inside #top-right - now fixed)
//
// Even with those fixes, if a search just completed, the table might still
// be updating its pagination/data, making the input unstable for a brief moment.
@@ -3946,7 +3946,7 @@ export class LogsPage {
await inputLocator.fill('');
} else {
// pressSequentially fires per-character input events that reliably
// trigger the q-input's debounced update:model-value chain.
// trigger the input's debounced update:model-value chain.
// force:true bypasses Monaco editor's <span class="highlight">code</span>
// overlay that can intercept pointer events on the splitter panel.
await inputLocator.click({ clickCount: 3, force: true });
@@ -5730,7 +5730,7 @@ export class LogsPage {
await this.page.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A");
await this.page.keyboard.press("Backspace");
// Drive the editor through Monaco's executeEdits API instead of keystrokes —
// typing into the search bar is intercepted by the q-input which has a
// typing into the search bar is intercepted by the input which has a
// debounced model-value chain that races the test's read. The Monaco model
// commits synchronously through executeEdits.
await this.page.evaluate(({ selector, text }) => {
@@ -5917,7 +5917,7 @@ export class LogsPage {
}
async toggleStreamSelection(streamName) {
// Post-OSelect-migration: the legacy q-select toggle data-test
// Post-OSelect-migration: the legacy select toggle data-test
// `log-search-index-list-stream-toggle-<name>` is no longer emitted.
// Pick the OSelect option whose `data-test-value` matches streamName.
// Caller must ensure the popover is open (e.g. via fillStreamFilter).
@@ -452,7 +452,7 @@ export class MetricsBuilderPage {
* Read the selected metric/stream value from the OSelect trigger's data-test
* attributes. OSelect exposes `data-test-selected-value` /
* `data-test-selected-label` on the inner PopoverTrigger (data-test
* `${parent}-trigger`). Falls back to inputValue() for legacy q-select.
* `${parent}-trigger`). Falls back to inputValue() for the legacy select.
*/
async getStreamSelectedValue() {
const wrapper = this.streamSelectorInput;
@@ -634,7 +634,7 @@ export class MetricsBuilderPage {
async clickAddOperation() {
await this.addOperationBtn.click();
// Operation selector is now an ODialog (see OperationsList.vue) — wait on the
// parent slug rather than the legacy `.q-dialog` wrapper.
// parent slug rather than the legacy dialog wrapper.
await this.operationDialog.waitFor({ state: 'visible', timeout: 5000 });
}
@@ -647,7 +647,7 @@ export class MetricsBuilderPage {
await this.operationDialog.waitFor({ state: 'visible', timeout: 5000 });
// Do NOT fill the search input: typing triggers a Vue re-render that temporarily
// collapses q-expansion-item sections, making items transiently invisible.
// collapses expansion-item sections, making items transiently invisible.
// The full un-filtered list is already rendered with all sections expanded.
// Find an operation option by data-test-value (preferred)
@@ -1206,11 +1206,11 @@ export class MetricsBuilderPage {
// OSelect post-migration: the PopoverTrigger button (`-trigger`) is
// the element that carries the disabled state when `:disabled` is
// set on the OSelect. We also check ancestor data-disabled, native
// disabled attr, and the legacy q-field--disabled class for safety.
// disabled attr, and the legacy disabled class for safety.
const hasDisabledClass = (node) => {
if (!node) return false;
const cls = node.className || '';
return cls.includes('q-field--disabled') || cls.includes('disabled');
return cls.includes('disabled');
};
// Find the OSelect trigger inside the wrapper (post-migration) — the
// disabled flag binds to the PopoverTrigger button.
@@ -619,7 +619,7 @@ export class MetricsPage {
// Query type switching methods
async switchToSQLMode() {
// Try to find and click SQL mode toggle/button
const sqlToggle = this.page.locator('[data-test*="sql"], button:has-text("SQL"), .q-toggle:has-text("SQL")').first();
const sqlToggle = this.page.locator('[data-test*="sql"], button:has-text("SQL")').first();
const isVisible = await sqlToggle.isVisible().catch(() => false);
if (isVisible) {
await sqlToggle.click();
@@ -630,7 +630,7 @@ export class MetricsPage {
async switchToPromQLMode() {
// Try to find and click PromQL mode toggle/button
const promqlToggle = this.page.locator('[data-test*="promql"], button:has-text("PromQL"), .q-toggle:has-text("PromQL")').first();
const promqlToggle = this.page.locator('[data-test*="promql"], button:has-text("PromQL")').first();
const isVisible = await promqlToggle.isVisible().catch(() => false);
if (isVisible) {
await promqlToggle.click();
@@ -882,7 +882,7 @@ export class MetricsPage {
const chartSelectors = {
'line': 'canvas, svg path, .apexcharts-line-series',
'pie': 'svg path[class*="pie"], .apexcharts-pie, path[class*="slice"]',
'table': 'table tbody tr, .q-table tbody tr, .data-table tbody tr',
'table': 'table tbody tr, .data-table tbody tr',
'heatmap': 'svg rect, .apexcharts-heatmap, .heatmap-cell',
'gauge': 'svg circle, .gauge-chart, .apexcharts-radialbar',
'bar': 'svg rect[class*="bar"], .apexcharts-bar-series, rect[class*="column"]'
@@ -899,7 +899,7 @@ export class MetricsPage {
}
async getTableHeaderCount() {
return await this.page.locator('table thead th, .q-table thead th').count();
return await this.page.locator('table thead th').count();
}
async getHeatmapCellCount() {
@@ -1502,7 +1502,7 @@ export class MetricsPage {
}
async getVisibleOptionElements() {
return this.page.locator('input, select, .q-toggle, .q-checkbox').locator('visible');
return this.page.locator('input, select').locator('visible');
}
async getVisibleOptionElementCount() {
@@ -1568,7 +1568,7 @@ export class MetricsPage {
// ===== CHART OPTIONS METHODS =====
async getVisibleChartOptions() {
return this.page.locator('.q-item:visible, [role="option"]:visible');
return this.page.locator('[role="option"]:visible');
}
// ===== LEGEND METHODS =====
@@ -1577,7 +1577,7 @@ export class MetricsPage {
// ===== MODE SELECTION METHODS =====
async getModeOptions() {
return this.page.locator('.q-item:has-text("SQL"), .q-item:has-text("PromQL")');
return this.page.locator('[role="option"]:has-text("SQL"), [role="option"]:has-text("PromQL")');
}
async getModeOptionCount() {
@@ -1657,7 +1657,7 @@ export class MetricsPage {
}
async getTableCellValues() {
const cells = await this.page.locator('table td, .q-table td, .data-table td').allTextContents();
const cells = await this.page.locator('table td, .data-table td').allTextContents();
return cells.filter(c => c.trim() !== '');
}
@@ -1690,7 +1690,7 @@ export class MetricsPage {
// ===== ADDITIONAL HELPER METHODS FOR metrics.spec.js =====
async hasTable() {
const dataTable = this.page.locator('.results-table, .data-table, table.q-table, [class*="table"], .q-table__middle, table').first();
const dataTable = this.page.locator('.results-table, .data-table, [class*="table"], table').first();
return await dataTable.isVisible().catch(() => false);
}
@@ -1705,7 +1705,7 @@ export class MetricsPage {
}
async getTableCells() {
return await this.page.locator('tbody td, .q-table__middle td, .table-cell').allTextContents();
return await this.page.locator('tbody td, .table-cell').allTextContents();
}
async getResultsPageText() {
@@ -2126,7 +2126,7 @@ export class MetricsPage {
// SQL mode methods
async getSqlToggle() {
return this.page.locator('[data-test*="sql"], button:has-text("SQL"), .q-toggle:has-text("SQL")').first();
return this.page.locator('[data-test*="sql"], button:has-text("SQL")').first();
}
async getSqlIndicator() {
@@ -2171,7 +2171,7 @@ export class MetricsPage {
* @returns {Locator}
*/
getThemeToggleButton() {
return this.page.locator('[data-test*="theme"], [class*="theme-toggle"], button:has-text("dark"), .q-toggle:has-text("dark")');
return this.page.locator('[data-test*="theme"], [class*="theme-toggle"], button:has-text("dark")');
}
/**
@@ -2436,7 +2436,7 @@ export class MetricsPage {
* Returns the share button's disabled tooltip locator if visible.
*/
getShareButtonDisabledTooltip() {
return this.page.locator('[role="tooltip"], .q-tooltip, [data-test*="tooltip"]').filter({ hasText: /Web URL|web.url|not configured/i });
return this.page.locator('[role="tooltip"], [data-test*="tooltip"]').filter({ hasText: /Web URL|web.url|not configured/i });
}
/**
@@ -133,7 +133,7 @@ export class PipelinesFormValidationPage {
async uploadPipelineFile(filePath) {
testLogger.info('Uploading pipeline file', { filePath });
// OFile (q-file) wraps a hidden native <input type="file"> — target it directly
// OFile wraps a hidden native <input type="file"> — target it directly
const nativeInput = this.page.locator(`${this.importPipelineFileInput} input[type="file"]`);
await nativeInput.setInputFiles(filePath);
}
@@ -250,7 +250,7 @@ export class PipelinesPage {
this.pipelineSavedMessage = page.locator('[data-test-message="Pipeline saved successfully"]');
this.addEnrichmentTableText = page.locator('[data-test="enrichment-tables-add-btn"]');
this.deletedSuccessfullyText = page.locator('[data-test-message*="deleted successfully"]');
this.conditionDropdown = page.locator("div:nth-child(2) > div:nth-child(2) > .q-field > .q-field__inner > .q-field__control > .q-field__control-container > .q-field__native");
this.conditionDropdown = page.locator("div:nth-child(2) > div:nth-child(2) input");
this.deleteButtonNth1 = page.locator("button").filter({ hasText: "delete" }).nth(1);
// Condition-specific locators for comprehensive testing
@@ -501,15 +501,15 @@ export class PipelinesPage {
await this.page.waitForLoadState('networkidle').catch(() => {});
await this.page.waitForTimeout(1000);
// The data-test attribute is on a wrapper div, the q-select is inside
// Target the q-select input inside the wrapper
// The data-test attribute is on a wrapper div, the select trigger is inside
// Target the select trigger inside the wrapper
const streamTypeWrapper = this.page.locator('[data-test="add-pipeline-stream-type-select"]');
await streamTypeWrapper.waitFor({ state: 'visible', timeout: 15000 });
// Click on the q-select component inside the wrapper
const qSelect = streamTypeWrapper.locator('.q-select');
await qSelect.waitFor({ state: 'visible', timeout: 10000 });
await qSelect.click();
// Click on the select trigger inside the wrapper
const streamTypeSelect = streamTypeWrapper.locator('[role="combobox"]');
await streamTypeSelect.waitFor({ state: 'visible', timeout: 10000 });
await streamTypeSelect.click();
await this.page.waitForTimeout(500);
}
@@ -963,7 +963,7 @@ export class PipelinesPage {
await this.dragStreamToTarget(this.conditionButton, { x: 250, y: 250 });
}
async fillColumnAndSelectOption(columnName) {
// Click the column select (q-select in FilterCondition)
// Click the column select in FilterCondition
await this.columnSelect.locator('input').click();
await this.columnSelect.locator('input').fill(columnName);
await this.columnOption.click();
@@ -3045,7 +3045,7 @@ export class PipelinesPage {
async testPauseToggle(rowIndex) {
const pipelineRows = this.page.locator('[data-test*="pipeline-row"], tr:has([data-test*="pipeline"])');
const row = pipelineRows.nth(rowIndex);
const pauseToggle = row.locator('[data-test*="pause"], [data-test*="toggle"], .q-toggle').first();
const pauseToggle = row.locator('[data-test*="pause"], [data-test*="toggle"]').first();
if (await pauseToggle.isVisible().catch(() => false)) {
const initialState = await pauseToggle.getAttribute('aria-pressed').catch(() => 'unknown');
@@ -3270,7 +3270,7 @@ export class PipelinesPage {
* @returns {Promise<boolean>} True if table is visible
*/
async isBackfillTableVisible() {
const tableLocator = this.page.locator('[data-test*="backfill-table"], table, .q-table').first();
const tableLocator = this.page.locator('[data-test*="backfill-table"], table').first();
return await tableLocator.isVisible({ timeout: 5000 }).catch(() => false);
}
@@ -3429,7 +3429,7 @@ export class PipelinesPage {
* @returns {Promise<boolean>} True if table is visible
*/
async isHistoryTableVisible() {
const tableLocator = this.page.locator('[data-test*="history-table"], table, .q-table').first();
const tableLocator = this.page.locator('[data-test*="history-table"], table').first();
return await tableLocator.isVisible({ timeout: 5000 }).catch(() => false);
}
@@ -3538,7 +3538,7 @@ export class PipelinesPage {
* @returns {Promise<boolean>} True if table is visible
*/
async isBackfillJobsTableVisible() {
const tableLocator = this.page.locator('[data-test*="backfill-table"], [data-test*="jobs-table"], table, .q-table').first();
const tableLocator = this.page.locator('[data-test*="backfill-table"], [data-test*="jobs-table"], table').first();
return await tableLocator.isVisible({ timeout: 5000 }).catch(() => false);
}
@@ -3548,9 +3548,9 @@ export class PipelinesPage {
*/
async isGenericTableVisible() {
// TODO(data-test): backfill/jobs/generic tables don't yet expose a data-test
// root in their source components — keeping native <table>/.q-table fallback
// root in their source components — keeping native <table> fallback
// until added in web/src/components/pipeline/ and shared table components.
const tableLocator = this.page.locator('table, .q-table').first();
const tableLocator = this.page.locator('table').first();
return await tableLocator.isVisible({ timeout: 5000 }).catch(() => false);
}
@@ -3573,12 +3573,12 @@ export class PipelinesPage {
* Filter by pipeline name
*/
async filterByPipeline() {
const pipelineFilter = this.page.locator('[data-test*="pipeline-filter"], [data-test*="pipeline-select"], .q-select').first();
const pipelineFilter = this.page.locator('[data-test*="pipeline-filter"], [data-test*="pipeline-select"]').first();
if (await pipelineFilter.isVisible().catch(() => false)) {
await pipelineFilter.click();
await this.page.waitForTimeout(500);
// Select first available option
const option = this.page.locator('.q-item').first();
const option = this.page.locator('[role="option"]').first();
if (await option.isVisible().catch(() => false)) {
await option.click();
}
@@ -3618,7 +3618,7 @@ export class PipelinesPage {
* @returns {Promise<number>} Count of buttons
*/
async getTableButtonCount() {
const buttons = await this.page.locator('table button, .q-table button').all();
const buttons = await this.page.locator('table button').all();
return buttons.length;
}
@@ -3903,7 +3903,7 @@ export class PipelinesPage {
/**
* Dismiss any open dialogs or menus.
* Use in afterEach cleanup instead of raw page.locator('.q-dialog').
* Use in afterEach cleanup instead of a raw dialog locator.
*/
async dismissOpenDialogs() {
try {
@@ -431,7 +431,7 @@ export class StreamsPage {
// Try individual selector
try {
const element = this.page.locator('tr:has-text("Full text search") .q-checkbox__inner');
const element = this.page.locator('tr:has-text("Full text search") [role="checkbox"]');
if (await element.isVisible({ timeout: 1000 })) {
await element.click();
}
@@ -464,7 +464,7 @@ export class StreamsPage {
async selectDateRange(startIso, endIso) {
// Post-revamp the extended-retention picker is the shared <date-time>
// component with an ODateRangeCalendar inside — Quasar's .q-date is gone.
// component with an ODateRangeCalendar inside — the old date input is gone.
// Day cells carry data-test="daterangecalendar-cell-<yyyy-mm-dd>";
// exclude adjacent-month duplicates via [data-outside-view].
await this.page.locator('[data-test="date-time-btn"]').click();
@@ -512,7 +512,7 @@ export class StreamsPage {
await this.selectDateRange(startIso, endIso);
// Applying the range saves immediately (dateChangeValue -> onSubmit),
// so the new row appears in the retention table. Return the start date
// in the table's display format (DD-MM-YYYY via convertUnixToQuasarFormat)
// in the table's display format (DD-MM-YYYY)
// so deleteRetentionPeriod's hasText row filter matches — the cells
// render e.g. "13-07-2026", not the ISO "2026-07-13".
const [y, m, d] = startIso.split('-');
@@ -1188,8 +1188,7 @@ export class StreamsPage {
/**
* Get full text search option from dropdown.
* StreamFieldInputs uses OSelect post-migration (Reka Listbox role=option);
* legacy q-select uses .q-item.
* StreamFieldInputs uses OSelect post-migration (Reka Listbox role=option).
*/
getFullTextSearchOption() {
return this.page.locator('[data-test$="-index-type-select-option"][data-test-value="fullTextSearchKey"]').first();
@@ -16,7 +16,7 @@ export class ServiceGraphPage {
this.refreshButton = '[data-test="service-graph-refresh-btn"]';
this.chartContainer = '[data-test="service-graph-chart"]';
// ===== VIEW TOGGLE BUTTONS (SearchBar.vue — q-btn with .selected class) =====
// ===== VIEW TOGGLE BUTTONS (SearchBar.vue — button with .selected class) =====
this.graphViewTab = '[data-test="service-graph-graph-view-btn"]';
this.treeViewTab = '[data-test="service-graph-tree-view-btn"]';
@@ -424,7 +424,7 @@ test.describe("Anomaly Detection Alerts", () => {
// Note: Status may be "waiting" or "ready" depending on training state
// We just verify it's no longer "disabled"
const row = pm.anomalyDetectionPage.getAnomalyRow(anomalyName);
// q-badge → OBadge migration: use data-test selector instead of .q-badge class
// OBadge migration: use data-test selector instead of the legacy badge class
await expect(row.locator('[data-test="anomaly-detection-status-badge"]')).not.toContainText('disabled', { ignoreCase: true, timeout: 5000 });
testLogger.info('Pause/resume works correctly');
@@ -2,7 +2,7 @@
* Scheduled Alert Features E2E Tests
*
* Tests scheduled alert features:
* 1. Alert History/Details dialog (q-dialog with paginated table)
* 1. Alert History/Details dialog (with paginated table)
* 2. Aggregation configuration in Step 2 (QueryConfig)
* 3. PromQL alert creation with trigger condition
* 4. SQL preview chart rendering
@@ -419,7 +419,7 @@ test.describe("Scheduled Alert Features", () => {
// Run the SQL flow end-to-end via PO method (Monaco-API driven, deterministic waits).
await pm.alertsPage.runSqlAndCloseEditor(`SELECT COUNT(*) as cnt FROM "${STREAM_NAME}"`);
// Cleanup: residual q-portal elements occasionally intercept clicks after dialog close.
// Cleanup: residual portal elements occasionally intercept clicks after dialog close.
await pm.alertsPage.removeResidualPortals();
testLogger.info('=== PHASE 3: Verify preview chart is visible ===');
@@ -777,7 +777,7 @@ test.describe("Cross-Linking testcases", () => {
const urlText = await pm.crossLinkPage.getCrossLinkItemUrlText(0);
expect(urlText).toContain('display.example.com');
// Verify field chips are rendered (CrossLinkManager shows OBadge for each field after q-chip → OBadge migration)
// Verify field chips are rendered (CrossLinkManager shows OBadge for each field after the chip → OBadge migration)
const chipCount = await pm.crossLinkPage.getFieldChipsCount(0);
expect(chipCount).toBe(2);
@@ -72,7 +72,7 @@ test.describe("ConfigPanel — Mark Line Settings", () => {
const valueAfter = page.locator('[data-test="dashboard-config-markline-value-0"]');
const nameAfter = page.locator('[data-test="dashboard-config-markline-name-0"]');
await pm.dashboardPanelConfigs.scrollSidebarToElement(valueAfter);
// q-select uses emit-value without map-options → displays raw stored value ("yAxis" not "Y-Axis")
// the select uses emit-value without map-options → displays raw stored value ("yAxis" not "Y-Axis")
await expect(page.locator('[data-test="dashboard-config-markline-type-0-trigger"]')).toHaveAttribute('data-test-selected-value', 'yAxis');
await expect(valueAfter.locator('[data-test$="-field"]')).toHaveValue("100");
await expect(nameAfter.locator('[data-test$="-field"]')).toHaveValue("threshold");
@@ -434,7 +434,7 @@ test.describe("Dashboard Create Alert testcases", () => {
await page.getByText(">=", { exact: true }).waitFor({ state: "visible", timeout: 5000 });
await page.getByText(">=", { exact: true }).click();
// The q-input may place data-test on the native <input> or on its root div,
// The input may place data-test on the native <input> or on its root div,
// so match both: 'input[data-test]' (on input) and '[data-test] input' (input inside parent)
const thresholdInput = page.locator(
'input[data-test="alert-threshold-value-input"], [data-test="alert-threshold-value-input"] input'
@@ -334,7 +334,7 @@ test.describe("dashboard share URL button testcases", () => {
.waitFor({ state: "hidden", timeout: 10000 })
.catch(() => {});
// Click the variable dropdown (inner q-select element)
// Click the variable dropdown (inner select element)
const variableDropdown = page.locator(
getVariableSelectorInner(variableName)
);
@@ -566,7 +566,7 @@ test.describe("dashboard share URL button testcases", () => {
.waitFor({ state: "hidden", timeout: 10000 })
.catch(() => {});
// Click the variable dropdown (inner q-select element)
// Click the variable dropdown (inner select element)
const variableDropdown = page.locator(
getVariableSelectorInner(variableName)
);
@@ -76,7 +76,7 @@ test.describe("Dashboard Table — Column Filtering (PR #12531)", () => {
// Verify the filter icon switches to its "active" state (isColFiltered →
// primary-colored icon) while the panel is still open — checking before
// Escape avoids a race with the dropdown's close/portal-unmount transition.
const activeFilterIcon = getColumnFilterBtn(page, 0).locator('[class*="color-primary-600"]');
const activeFilterIcon = getColumnFilterBtn(page, 0).locator('[data-test$="-active"]');
await expect(activeFilterIcon).toBeVisible();
await page.keyboard.press("Escape");
@@ -725,7 +725,7 @@ test.describe("Dashboard Variables - Dependency Loading", { tag: ['@dashboards',
// Reopen settings and navigate to variables tab
await pm.dashboardSetting.openSetting();
// Wait for the settings ODrawer to be visible (replaced legacy .q-dialog selector)
// Wait for the settings ODrawer to be visible (replaced the legacy dialog selector)
await page.locator('[data-test="dashboard-settings-drawer"]').waitFor({ state: "visible", timeout: 5000 });
await pm.dashboardSetting.openVariables();
await page.locator('[data-test="dashboard-add-variable-btn"]').waitFor({ state: "visible", timeout: 10000 });
@@ -70,7 +70,7 @@ test.describe("Dashboard Variables - Global Level", { tag: ['@dashboards', '@das
// Verify variable scope is global in the settings
await pm.dashboardSetting.openSetting();
// Wait for the settings ODrawer to be visible (replaced legacy .q-dialog selector)
// Wait for the settings ODrawer to be visible
const settingsDrawer = page.locator('[data-test="dashboard-settings-drawer"]');
await settingsDrawer.waitFor({ state: "visible", timeout: 5000 });
await pm.dashboardSetting.openVariables();
@@ -322,8 +322,8 @@ export async function savePanel(page) {
// Wait for ODropdown date-time menu and portal menus to be hidden
await page.locator('#date-time-menu').first().waitFor({ state: "hidden", timeout: 3000 }).catch(() => {});
// Also wait for any date picker portal menus to close (they use q-portal--menu--* IDs)
await page.locator('[id^="q-portal--menu--"]').first().waitFor({ state: "hidden", timeout: 3000 }).catch(() => {});
// Also wait for any date picker portal menus to close (reka-ui portalled content)
await page.locator('[data-reka-popper-content-wrapper]').first().waitFor({ state: "hidden", timeout: 3000 }).catch(() => {});
// Click somewhere neutral to dismiss any remaining overlays (like date picker)
await page.locator('[data-test="dashboard-panel-name"]').click().catch(() => {});
@@ -57,10 +57,12 @@ test.describe("Theme Management Tests", () => {
await page.reload();
await page.waitForLoadState('domcontentloaded');
// Wait for theme to be restored from storage (Vue needs time to apply it)
// Wait for theme to be restored from storage (Vue needs time to apply it).
// The dark-mode signal is the `.dark` class on <html> (set by
// utils/theme.ts); the legacy `body--dark` on <body> was retired.
await page.waitForFunction(
(darkClass) => document.body.classList.contains(darkClass),
'body--dark',
(darkClass) => document.documentElement.classList.contains(darkClass),
'dark',
{ timeout: 10000 }
);
@@ -1290,8 +1290,8 @@ test.describe("Metrics PromQL Builder Mode testcases", () => {
if (await dropdownBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
await dropdownBtn.click();
}
// PanelContainer uses ODropdown (Reka UI), not the q-menu — wait for
// the edit menu item directly instead of waiting for a .q-menu element.
// PanelContainer uses ODropdown (Reka UI) — wait for the edit menu item
// directly instead of waiting for the dropdown menu container.
// Click Edit Panel menu item
const editMenuItem = builder.getEditPanelMenuItem();
@@ -165,7 +165,7 @@ test.describe("PromQL Table Chart - Column Order Feature", () => {
testLogger.info('Column Order popup opened successfully');
// Verify description text is present (ODialog header carries the title via :title prop,
// it doesn't render a .q-table__title block any more — assert the body's description data-test instead).
// it doesn't render a table title block any more — assert the body's description data-test instead).
const descriptionText = pm.dashboardPanelConfigs.columnOrderDescription;
await expect(descriptionText).toBeVisible();
testLogger.info('Column Order popup has description text');
@@ -280,7 +280,7 @@ test.describe("Alerts Regression Bugs — Batch 1", () => {
testLogger.info('Import dialog opened with upload functionality');
// Bug verification: no premature "already exists" error before import
const errorMessages = page.locator('[class*="error"], [class*="negative"], .q-banner')
const errorMessages = page.locator('[class*="error"], [class*="negative"]')
.filter({ hasText: /already exists|duplicate/i });
const hasPrematureError = await errorMessages.first().isVisible({ timeout: 2000 }).catch(() => false);
@@ -460,7 +460,7 @@ test.describe("Alerts Stream Switching Regression", () => {
// Remove interfering portal elements
await page.evaluate(() => {
document.querySelectorAll('div[id^="q-portal"]').forEach(el => {
document.querySelectorAll('[data-reka-portalled], [data-reka-popper-content-wrapper]').forEach(el => {
if (el.getAttribute('aria-hidden') === 'true') el.style.display = 'none';
});
}).catch(e => testLogger.warn('Failed to remove portal elements', { error: e.message }));
@@ -1439,7 +1439,7 @@ test.describe("Logs Regression Bug Fixes", () => {
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
await page.waitForTimeout(2000);
const errorVisible = await page.locator('[class*="error"], [class*="negative"], .q-banner, .q-notification')
const errorVisible = await page.locator('[class*="error"], [class*="negative"], [role="alert"]')
.filter({ hasText: /error|failed|invalid/i })
.first().isVisible({ timeout: 2000 }).catch(() => false);
@@ -1482,7 +1482,7 @@ test.describe("Logs Regression Bugs", () => {
// Compare that the classes from before expansion are still present
expect(lastRowFinalClasses).toBeTruthy();
// Filter out generic/transient table classes that don't indicate highlighting state
const ignoredClasses = new Set(['table-row', 'q-tr', 'cursor-pointer']);
const ignoredClasses = new Set(['table-row', 'cursor-pointer']);
const originalClassSet = new Set(lastRowClasses.split(/\s+/).filter(c => c && !ignoredClasses.has(c)));
const finalClassSet = new Set(lastRowFinalClasses.split(/\s+/).filter(c => c && !ignoredClasses.has(c)));
@@ -185,7 +185,7 @@ async function multistreamselect(page) {
// await page.locator('[data-test="date-time-btn"]').click({ force: true });
// await page
// .locator('[data-test="date-time-relative-6-w-btn"] > .q-btn__content')
// .locator('[data-test="date-time-relative-6-w-btn"] button')
// .click({
// force: true,
// });
@@ -196,16 +196,16 @@ async function multistreamselect(page) {
// force: true,
// });
// await page.waitForTimeout(1000);
// await expect(page.locator(".q-notification__message")).toContainText(
// await expect(page.locator('[role="alert"]')).toContainText(
// "Live mode is enabled"
// );
// await page.waitForTimeout(5000);
// await page
// .locator(".q-pl-sm > .q-btn > .q-btn__content")
// .locator('[data-test="logs-search-off-refresh-interval"] button')
// .click({ force: true });
// await page
// .locator(
// '[data-test="logs-search-off-refresh-interval"] > .q-btn__content'
// '[data-test="logs-search-off-refresh-interval"] button'
// )
// .click({ force: true });
// await applyQueryButton(page);
@@ -122,7 +122,7 @@ async function tryExpandField(page, pm) {
}
// Strategy 2: Click on field expansion headers in the traces field list
// Traces uses q-expansion-item with class 'field-expansion-item'
// Traces renders field rows with class 'field-expansion-item'
// The clickable header has class 'field-expansion-header'
// Using page object selector for the fields table
const fieldsTableSelector = pm.tracesPage.fieldsTable || '[data-test="log-search-index-list-fields-table"]';
+51
View File
@@ -0,0 +1,51 @@
{
"overrides": [
{
"comment": "base.css owns the @font-face blocks, which must name real families. It is the ONE file allowed to write a literal font-family; the --font-sans/--font-mono tokens it defines are what everything else consumes.",
"files": ["src/lib/styles/tokens/base.css"],
"rules": {
"declaration-property-value-disallowed-list": [
{
"/.*/": ["/var\\(\\s*--o2-/", "/var\\(\\s*--color-text-(primary|caption)\\b/"]
},
{
"message": "Banned token. --o2-* is legacy; --color-text-primary/-caption were retired (Tier 1) — use --color-text-heading / --color-text-secondary.",
"severity": "error"
}
]
}
},
{
"files": ["**/*.vue"],
"customSyntax": "postcss-html",
"rules": {
"color-no-hex": [
true,
{
"message": "Hardcoded hex is banned in .vue styles. Use a --color-* token (§3.G).",
"severity": "error"
}
],
"selector-disallowed-list": [
["/\\.body--(dark|light)/", "/\\.(light|dark)-mode/"],
{
"message": "Dark mode CSS uses .dark only (Tailwind dark: variant or :where(.dark) &). §3.R",
"severity": "error"
}
]
}
}
],
"rules": {
"declaration-property-value-disallowed-list": [
{
"/.*/": ["/var\\(\\s*--o2-/", "/var\\(\\s*--color-text-(primary|caption)\\b/"],
"font-family": ["/^(?!.*(var\\(--font-(sans|mono)\\)|inherit)).*$/"]
},
{
"message": "Banned value. --o2-* is legacy; --color-text-primary/-caption were retired (Tier 1) — use --color-text-heading / --color-text-secondary. font-family must be var(--font-sans) or var(--font-mono) — no literal font stacks (FONT_AUDIT.md).",
"severity": "error"
}
]
}
}
+92 -270
View File
@@ -7,6 +7,70 @@ import prettier from "eslint-plugin-prettier";
import cypress from "eslint-plugin-cypress";
import fs from "fs";
// Bans the legacy --o2-* CSS custom-property vocabulary anywhere in a .vue/.ts
// file's raw text — catches Tailwind arbitrary-value usages in templates
// (e.g. class="bg-[var(--o2-*)]"), <style> blocks, and JS string literals.
// This is the enforcing gate for the --o2- ban (stylelint can't allowlist values).
//
// ALLOWLIST: EMPTY on purpose. Every `--o2-*` custom property is a hard error —
// including the ones that still "work" at runtime (OTable tree indents, row-status,
// the dynamic `--o2-span-*` palette). The goal is to eliminate the --o2-* vocabulary
// entirely, so these must be renamed off the namespace (to a --color-* token, a
// Tailwind utility, or a non-o2 runtime custom property). Do NOT add exemptions here;
// fixing the underlying usage is the only way to make this rule pass.
const O2_ALLOWLIST = new Set([]);
const O2_ALLOW_PREFIXES = [];
// A `--o2-*` is a banned CSS custom property only when USED as one: inside var(),
// a Tailwind shorthand/arbitrary bracket, or as a declaration / `:style` key
// (immediately followed by `:`). The `--o2-` prefix also collides with the
// OpenObserve collector's CLI flags (e.g. the k8s installer's `--o2-<flag>=<value>`
// URL flag) and with prose/comments that name them — those are NOT CSS tokens and
// must not trip the ban. This is the same discrimination as scripts/check-css-tokens.mjs.
const isO2CssUsage = (text, index, name) => {
const before = text.slice(Math.max(0, index - 8), index);
const after = text.slice(index + name.length, index + name.length + 4);
return (
/var\(\s*$/.test(before) || // var(--o2-*
/[A-Za-z0-9\]]-\(\s*$/.test(before) || // bg-(--o2-* (Tailwind shorthand)
/\[\s*$/.test(before) || // [--o2-* (arbitrary property/value)
/^\s*['"]?\s*:/.test(after) // --o2-*: or '--o2-*': (decl / :style key)
);
};
const noLegacyO2Tokens = {
rules: {
"no-legacy-o2-tokens": {
meta: { type: "problem", docs: { description: "Ban legacy --o2-* CSS custom properties" } },
create(context) {
const allowed = (name) =>
O2_ALLOWLIST.has(name) || O2_ALLOW_PREFIXES.some((re) => re.test(name));
return {
Program() {
const sourceCode = context.sourceCode ?? context.getSourceCode();
const text = sourceCode.getText();
const re = /--o2-[A-Za-z0-9-]+/g;
let match;
while ((match = re.exec(text))) {
if (allowed(match[0])) continue;
if (!isO2CssUsage(text, match.index, match[0])) continue; // CLI flag / prose
const start = match.index;
const end = start + match[0].length;
context.report({
loc: {
start: sourceCode.getLocFromIndex(start),
end: sourceCode.getLocFromIndex(end),
},
message: `Legacy CSS token "${match[0]}" is banned. Use the modern --color-* token or a Tailwind utility.`,
});
}
},
};
},
},
},
};
// Read .gitignore to use as ignore patterns
const gitignore = fs.existsSync(".gitignore")
? fs
@@ -44,8 +108,28 @@ export default [
vue,
"@typescript-eslint": typescript,
prettier,
"local": noLegacyO2Tokens,
},
rules: {
"local/no-legacy-o2-tokens": ["error"],
// Dark-mode schema (O2_TOKEN_MIGRATION_PLAN §3.R.3) — warn now, error at Phase G.
// The two sanctioned seams (useTheme.ts / chartTheme.ts) turn this off below.
"no-restricted-syntax": [
"warn",
{
selector:
"BinaryExpression[operator=/^[!=]==?$/] > MemberExpression[property.name='theme']",
message:
"Dark mode has one JS seam: useTheme().isDark or chartColor(). Do not compare store.state.theme (§3.R).",
},
{
selector: "VariableDeclarator[id.name=/^(isDark|isDarkMode|darkMode)$/]",
message: "Import useTheme() instead of a private isDark flag (§3.R).",
},
],
// Every <style> block must be scoped (§3.H) — warn now, error at Phase F.
"vue/enforce-style-attribute": ["warn", { allow: ["scoped", "module"] }],
// Disable noisy rules inherited from recommended configs
"prettier/prettier": "off",
"no-unused-vars": "off",
@@ -86,276 +170,14 @@ export default [
"vue/no-ref-as-operand": "off",
"vue/multi-word-component-names": "off",
// Enforced rules
"vue/no-restricted-html-elements": [
"error",
// Dropdown / list
{
element: "q-menu",
message:
'Use <ODropdown> from "@/lib/overlay/Dropdown/ODropdown.vue" instead of <q-menu>.',
},
{
element: "q-list",
message:
'Drop <q-list> inside <ODropdown> (not needed), or replace standalone <q-list> with a native <ul> / <div> + Tailwind.',
},
{
element: "q-item",
message:
'Use <ODropdownItem> from "@/lib/overlay/Dropdown/ODropdownItem.vue" (inside <ODropdown>), or a native <li> / <div> + Tailwind (display rows).',
},
{
element: "q-item-section",
message:
"Use <ODropdownItem>'s #icon-left / #default / #icon-right slots (inside <ODropdown>), or a native <div class=\"flex ...\"> (display).",
},
{
element: "q-item-label",
message:
'Use plain text / <span class="text-sm">, <span class="block text-xs text-muted-foreground"> (caption), or <ODropdownGroup :label="..."> (header) instead of <q-item-label>.',
},
// Button
{
element: "q-btn",
message: 'Use <OButton> from "@/lib/core/Button/OButton.vue" instead of <q-btn>.',
},
{
element: "q-btn-group",
message: 'Use <OButtonGroup> from "@/lib/core/Button/OButtonGroup.vue" instead of <q-btn-group>.',
},
{
element: "q-btn-toggle",
message: 'Use <OToggleGroup> from "@/lib/core/ToggleGroup/OToggleGroup.vue" instead of <q-btn-toggle>.',
},
{
element: "q-btn-dropdown",
message: 'Use <OButton> with <ODropdown> instead of <q-btn-dropdown>.',
},
// Tabs
{
element: "q-tabs",
message: 'Use <OTabs> from "@/lib/navigation/Tabs/OTabs.vue" instead of <q-tabs>.',
},
{
element: "q-tab",
message: 'Use <OTab> from "@/lib/navigation/Tabs/OTab.vue" instead of <q-tab>.',
},
{
element: "q-tab-panels",
message: 'Use <OTabPanels> from "@/lib/navigation/Tabs/OTabPanels.vue" instead of <q-tab-panels>.',
},
{
element: "q-tab-panel",
message: 'Use <OTabPanel> from "@/lib/navigation/Tabs/OTabPanel.vue" instead of <q-tab-panel>.',
},
{
element: "q-route-tab",
message: 'Use <ORouteTab> from "@/lib/navigation/Tabs/ORouteTab.vue" instead of <q-route-tab>.',
},
// Toolbar / layout primitives
{
element: "q-bar",
message: "Use a plain <div> instead of <q-bar>.",
},
{
element: "q-toolbar",
message: "Use a plain <div> instead of <q-toolbar>.",
},
{
element: "q-toolbar-title",
message: "Remove <q-toolbar-title> — use a plain <div> or <span> for the title content.",
},
// Icon
{
element: "q-icon",
message: 'Use <OIcon> from "@/lib/core/Icon/OIcon.vue" instead of <q-icon>. To add a new icon update OIcon.icons.ts.',
},
// Overlay
{
element: "q-dialog",
message: 'Use <ODialog> from "@/lib/overlay/Dialog/ODialog.vue" for modals, or <ODrawer> from "@/lib/overlay/Drawer/ODrawer.vue" for side-panel drawers, instead of <q-dialog>.',
},
{
element: "q-tooltip",
message: 'Use <OTooltip> from "@/lib/overlay/Tooltip/OTooltip.vue" instead of <q-tooltip>.',
},
{
element: "q-drawer",
message: 'Use <ODrawer> from "@/lib/overlay/Drawer/ODrawer.vue" instead of <q-drawer>.',
},
// Badge / chip / avatar / image
{
element: "q-badge",
message: 'Use <OBadge> from "@/lib/core/Badge/OBadge.vue" instead of <q-badge>.',
},
{
element: "q-chip",
message: 'Use <OBadge> from "@/lib/core/Badge/OBadge.vue" instead of <q-chip>.',
},
{
element: "q-avatar",
message: 'Use a plain <div class="rounded-full ..."> wrapper with an inner <OIcon> or <img> instead of <q-avatar>.',
},
{
element: "q-img",
message: "Use a native <img> element instead of <q-img>. q-img features (lazy load, aspect-ratio, spinner) are not used anywhere in this codebase.",
},
// Card
{
element: "q-card",
message: 'Use <OCard> from "@/lib/core/Card/OCard.vue" instead of <q-card>.',
},
{
element: "q-card-actions",
message: 'Use <OCardActions> from "@/lib/core/Card/OCardActions.vue" instead of <q-card-actions>.',
},
{
element: "q-card-section",
message: 'Use <OCardSection> from "@/lib/core/Card/OCardSection.vue" instead of <q-card-section>.',
},
// Collapsible / separator / splitter
{
element: "q-expansion-item",
message: 'Use <OCollapsible> from "@/lib/core/Collapsible/OCollapsible.vue" instead of <q-expansion-item>.',
},
{
element: "q-separator",
message: 'Use <OSeparator> from "@/lib/core/Separator/OSeparator.vue" instead of <q-separator>.',
},
{
element: "q-splitter",
message: 'Use <OSplitter> from "@/lib/core/Splitter/OSplitter.vue" instead of <q-splitter>.',
},
// Navigation
{
element: "q-pagination",
message: 'Use <OPagination> from "@/lib/navigation/Pagination/OPagination.vue" instead of <q-pagination>.',
},
{
element: "q-stepper",
message: 'Use <OStepper> from "@/lib/navigation/Stepper/OStepper.vue" instead of <q-stepper>.',
},
{
element: "q-step",
message: 'Use <OStep> from "@/lib/navigation/Stepper/OStep.vue" instead of <q-step>.',
},
{
element: "q-stepper-navigation",
message: "Remove <q-stepper-navigation> — place Back/Continue buttons in a single nav div outside </OStepper>.",
},
// Data display
{
element: "q-table",
message: 'Use <OTable> from "@/lib/core/Table/OTable.vue" instead of <q-table>.',
},
{
element: "q-timeline",
message: 'Use <OTimeline> from "@/lib/data/Timeline/OTimeline.vue" instead of <q-timeline>.',
},
{
element: "q-timeline-entry",
message: 'Use <OTimelineItem> from "@/lib/data/Timeline/OTimelineItem.vue" instead of <q-timeline-entry>.',
},
{
element: "q-tree",
message: 'Use <OTree> from "@/lib/data/Tree/OTree.vue" instead of <q-tree>.',
},
{
element: "q-virtual-scroll",
message: 'Use <OVirtualScroll> from "@/lib/core/VirtualScroll/OVirtualScroll.vue" instead of <q-virtual-scroll>.',
},
// Feedback / loading
{
element: "q-spinner",
message: 'Use <OSpinner> from "@/lib/feedback/Spinner/OSpinner.vue" instead of <q-spinner>.',
},
{
element: "q-spinner-hourglass",
message: 'Use <OSpinner> from "@/lib/feedback/Spinner/OSpinner.vue" instead of <q-spinner-hourglass>.',
},
{
element: "q-spinner-dots",
message: 'Use <OSpinner variant="dots"> from "@/lib/feedback/Spinner/OSpinner.vue" instead of <q-spinner-dots>.',
},
{
element: "q-spinner-gears",
message: 'Use <OSpinner> from "@/lib/feedback/Spinner/OSpinner.vue" instead of <q-spinner-gears>.',
},
{
element: "q-circular-progress",
message: 'Use <OSpinner> from "@/lib/feedback/Spinner/OSpinner.vue" instead of <q-circular-progress>.',
},
{
element: "q-linear-progress",
message: 'Use <OProgressBar> from "@/lib/data/ProgressBar/OProgressBar.vue" instead of <q-linear-progress>.',
},
{
element: "q-inner-loading",
message: 'Use <OInnerLoading> from "@/lib/feedback/InnerLoading/OInnerLoading.vue" instead of <q-inner-loading>.',
},
{
element: "q-skeleton",
message: 'Use <OSkeleton> from "@/lib/feedback/Skeleton/OSkeleton.vue" instead of <q-skeleton>.',
},
{
element: "q-banner",
message: 'Use <OBanner> from "@/lib/feedback/Banner/OBanner.vue" instead of <q-banner>.',
},
// Forms
{
element: "q-form",
message: 'Use <OForm> from "@/lib/forms/Form/OForm.vue" instead of <q-form>.',
},
{
element: "q-input",
message: 'Use <OInput> from "@/lib/forms/Input/OInput.vue" instead of <q-input>. For textarea use <OTextarea>. For form-bound fields use <OFormInput> / <OFormTextarea>.',
},
{
element: "q-select",
message: 'Use <OSelect> from "@/lib/forms/Select/OSelect.vue" instead of <q-select>. For form-bound fields use <OFormSelect>.',
},
{
element: "q-checkbox",
message: 'Use <OCheckbox> / <OCheckboxGroup> from "@/lib/forms/Checkbox/OCheckbox.vue" instead of <q-checkbox>. For form-bound fields use <OFormCheckbox>.',
},
{
element: "q-radio",
message: 'Use <ORadio> inside <ORadioGroup> from "@/lib/forms/Radio/" instead of <q-radio>. Wrap items in <ORadioGroup v-model="...">.',
},
{
element: "q-toggle",
message: 'Use <OSwitch> from "@/lib/forms/Switch/OSwitch.vue" instead of <q-toggle>. For form-bound fields use <OFormSwitch>.',
},
{
element: "q-slider",
message: 'Use <OSlider> from "@/lib/forms/Slider/OSlider.vue" instead of <q-slider>. For form-bound fields use <OFormSlider>.',
},
{
element: "q-range",
message: 'Use <ORange> from "@/lib/forms/Range/ORange.vue" instead of <q-range>. For form-bound fields use <OFormRange>.',
},
{
element: "q-file",
message: 'Use <OFile> from "@/lib/forms/File/OFile.vue" instead of <q-file>. For form-bound fields use <OFormFile>.',
},
{
element: "q-date",
message: 'Use <ODate> from "@/lib/forms/Date/ODate.vue" instead of <q-date>. For form-bound fields use <OFormDate>.',
},
{
element: "q-time",
message: 'Use <OTime> from "@/lib/forms/Time/OTime.vue" instead of <q-time>. For form-bound fields use <OFormTime>.',
},
{
element: "q-color",
message: 'Use <OColor> from "@/lib/forms/Color/OColor.vue" instead of <q-color>. For form-bound fields use <OFormColor>.',
},
{
element: "q-option-group",
message: 'Use <OOptionGroup> from "@/lib/forms/OptionGroup/OOptionGroup.vue" instead of <q-option-group>. For form-bound fields use <OFormOptionGroup>.',
},
],
},
},
{
// The two sanctioned dark-mode seams (§3.R.1) — the only files allowed to
// compare store.state.theme / declare an isDark flag.
files: ["src/composables/useTheme.ts", "src/utils/chartTheme.ts"],
rules: {
"no-restricted-syntax": "off",
},
},
{
+1042 -64
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -23,7 +23,12 @@
"build-only-cloud-prod": "vite build --mode=production",
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"lint:ci": "eslint \"src/**/*.vue\" --no-fix"
"lint:ci": "eslint \"src/**/*.vue\" --no-fix",
"lint:styles": "stylelint \"src/**/*.{css,vue}\"",
"lint:tokens": "node scripts/check-css-tokens.mjs",
"lint:token-purity": "node scripts/check-token-purity.mjs",
"lint:design": "node scripts/check-design-consistency.mjs",
"lint:design:strict": "node scripts/check-design-consistency.mjs --strict"
},
"dependencies": {
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
@@ -52,7 +57,6 @@
"echarts-gl": "^2.1.0",
"escodegen": "^2.1.0",
"flag-icons": "^7.5.0",
"gridstack": "^12.4.2",
"highlight.js": "^11.11.1",
"js-beautify": "^1.15.4",
@@ -129,12 +133,14 @@
"msw": "^2.12.7",
"npm-run-all": "^4.1.5",
"postcss": "^8.5.10",
"postcss-html": "^1.8.1",
"prettier": "^3.7.4",
"rollup-plugin-analyzer": "^4.0.0",
"rollup-plugin-visualizer": "^6.0.5",
"sass": "^1.97.1",
"sass-loader": "^16.0.6",
"start-server-and-test": "^2.1.3",
"stylelint": "^17.14.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"unplugin-icons": "^23.0.1",
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env node
// CI guard: fail if any `var(--x)` reference has no matching `--x:` definition
// anywhere in src (.css / .vue / .ts), and no CSS fallback (`var(--x, fallback)`).
// This catches the root-cause class of bug fixed in O2_TOKEN_MIGRATION_PLAN.md §3
// (undefined-token references that silently void the whole declaration).
//
// Run: node scripts/check-css-tokens.mjs (from web/)
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, extname } from "node:path";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC_DIR = join(__dirname, "..", "src");
const EXTS = new Set([".css", ".vue", ".ts"]);
// Library-injected custom properties that are never statically `--x: value;`
// defined in our source (Reka UI, Tailwind internals, vue-flow).
// These resolve at runtime, so a static "undefined" report on them would be a
// false positive — hence the prefix allowlist.
//
// HARD RULE: NO `--o2-*` name may ever appear in any allowlist here. The entire
// `--o2-*` vocabulary is banned and being migrated away; even the ones set at
// runtime via Vue `:style` (OTable tree indents, row status, row height) must be
// renamed off the `--o2-` namespace. If you find a `--o2-*` here, delete it and
// fix the usage — never re-exempt it.
const ALLOW_PREFIXES = [/^--reka-/, /^--tw-/, /^--vf-/];
const ALLOW_EXACT = new Set([
// Non-o2 custom properties set per-element at runtime via Vue `:style`
// bindings — dynamic values with no static `--x:` definition.
"--node-color", // plugins/pipelines/CustomNode.vue :style={ '--node-color': ... }
"--chip-color",
]);
// Guard against accidental re-introduction of an --o2-* exemption above.
for (const name of [...ALLOW_EXACT, ...ALLOW_PREFIXES.map((re) => re.source)]) {
if (String(name).includes("--o2-")) {
console.error(`check-css-tokens: '${name}' — --o2-* tokens must never be allowlisted. Remove it.`);
process.exit(2);
}
}
function isAllowed(name) {
if (ALLOW_EXACT.has(name)) return true;
return ALLOW_PREFIXES.some((re) => re.test(name));
}
function walk(dir, files = []) {
for (const entry of readdirSync(dir)) {
if (entry === "node_modules" || entry === "dist") continue;
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) walk(full, files);
else if (EXTS.has(extname(entry))) files.push(full);
}
return files;
}
const DECL_RE = /(--[A-Za-z0-9_-]+)\s*:/g;
// var(--x) or var(--x, fallback) — only flag the no-fallback form.
const VAR_RE = /var\(\s*(--[A-Za-z0-9_-]+)\s*(,[^)]*)?\)/g;
// Tailwind v4 CSS-variable shorthand: `bg-(--x)`, `text-(--x)`, `border-(--x)`.
// The compiler expands these to a bare `var(--x)` with NO fallback, so a phantom
// token voids the declaration exactly like `var(--x)` does — but VAR_RE never
// sees the `var(` text, so this form slipped past the guard entirely. The
// fallback form `(--x,#fff)` is safe and skipped, mirroring VAR_RE.
// Requires a utility prefix (`bg`, `text`, `border-t`, …) before the `-(`, which
// is what distinguishes the shorthand from an ordinary `var(` / `calc(` call.
const TW_SHORTHAND_RE = /[A-Za-z0-9\]]-\(\s*(--[A-Za-z0-9_-]+)\s*(,[^)]*)?\)/g;
// ANY --o2-* occurrence used AS A CSS CUSTOM PROPERTY, in any syntax (var(),
// Tailwind `[var(--o2-*)]` / `(--o2-*)` shorthand, `[--o2-*]` arbitrary,
// `:style` keys, template-literal CSS). The whole vocabulary is banned, so a
// fallback does NOT make it acceptable — unlike the undefined-ref check.
const O2_RE = /--o2-[A-Za-z0-9-]+/g;
// The `--o2-` prefix collides with the OpenObserve collector's CLI flags (e.g.
// the k8s install command's `--o2-<flag>=<value>` URL flag) and with
// prose/comments that name those flags. Those are NOT CSS custom properties, so
// they must not trip the ban. A `--o2-*` is a CSS custom property only when used
// as one: inside var(), a Tailwind shorthand/arbitrary bracket, or as a
// declaration / `:style` key (immediately followed by `:`). Everything else (a
// bare flag in a shell-command string, or a backticked mention in a comment) is
// ignored.
function isO2CssUsage(line, index, name) {
const before = line.slice(Math.max(0, index - 6), index);
const after = line.slice(index + name.length);
return (
/var\(\s*$/.test(before) || // var(--o2-*
/[A-Za-z0-9\]]-\(\s*$/.test(before) || // bg-(--o2-* (Tailwind shorthand)
/\[\s*$/.test(before) || // [--o2-* (arbitrary property/value)
/^\s*['"]?\s*:/.test(after) // --o2-*: or '--o2-*': (decl / :style key)
);
}
const defined = new Set();
const referenced = new Map(); // name -> [{file, line}]
const bannedO2 = new Map(); // name -> [{file, line}]
for (const file of walk(SRC_DIR)) {
const text = readFileSync(file, "utf8");
const lines = text.split("\n");
lines.forEach((line, i) => {
for (const m of line.matchAll(DECL_RE)) defined.add(m[1]);
for (const re of [VAR_RE, TW_SHORTHAND_RE]) {
for (const m of line.matchAll(re)) {
const [, name, fallback] = m;
if (fallback) continue; // has a fallback, safe either way
if (!referenced.has(name)) referenced.set(name, []);
referenced.get(name).push({ file: file.replace(SRC_DIR + "/", "src/"), line: i + 1 });
}
}
for (const m of line.matchAll(O2_RE)) {
const name = m[0];
if (!isO2CssUsage(line, m.index, name)) continue; // CLI flag / prose, not a CSS token
if (!bannedO2.has(name)) bannedO2.set(name, []);
bannedO2.get(name).push({ file: file.replace(SRC_DIR + "/", "src/"), line: i + 1 });
}
});
}
const undefinedRefs = [];
for (const [name, sites] of referenced) {
if (defined.has(name) || isAllowed(name)) continue;
undefinedRefs.push({ name, sites });
}
function printGroup(map) {
for (const [name, sites] of map) {
console.error(` ${name}`);
for (const { file, line } of sites.slice(0, 5)) console.error(` ${file}:${line}`);
if (sites.length > 5) console.error(` ...and ${sites.length - 5} more`);
}
}
let failed = false;
// 1) Banned --o2-* vocabulary — zero tolerance, fallback or not.
if (bannedO2.size > 0) {
const total = [...bannedO2.values()].reduce((n, s) => n + s.length, 0);
console.error(`\nFound ${total} banned --o2-* token reference${total === 1 ? "" : "s"} (${bannedO2.size} distinct name${bannedO2.size === 1 ? "" : "s"}):\n`);
printGroup(bannedO2);
console.error("\nThe entire --o2-* vocabulary is banned. Migrate each to its --color-* equivalent");
console.error("(see scripts/o2-token-map.json) or a Tailwind utility. There is no allowlist.\n");
failed = true;
}
// 2) Undefined non-o2 references with no fallback (voids the whole declaration).
if (undefinedRefs.length > 0) {
console.error(`\nFound ${undefinedRefs.length} undefined CSS custom propert${undefinedRefs.length === 1 ? "y" : "ies"} referenced with no fallback:\n`);
printGroup(new Map(undefinedRefs.map((r) => [r.name, r.sites])));
console.error("\nEach of these voids its whole declaration in browsers (invalid var() = unset).");
console.error("Either define the token or add a var(--x, fallback).\n");
failed = true;
}
if (failed) {
process.exit(1);
} else {
console.log(`OK — no --o2-* tokens and no undefined CSS custom property references (${defined.size} tokens defined, ${referenced.size} distinct refs checked).`);
}
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env node
// CI ratchet: count per-file occurrences of every design-token bypass category
// from O2_TOKEN_MIGRATION_PLAN.md §3 (A–R). Debt can only shrink.
//
// node scripts/check-design-consistency.mjs # fail if any file/category exceeds baseline
// node scripts/check-design-consistency.mjs --strict # ALSO fail if the baseline is stale (has slack) — CI mode
// node scripts/check-design-consistency.mjs --baseline # (re)write design-debt-baseline.json
//
// --strict is what CI runs (lint:design:strict). Plain mode lets a file sit
// below its baseline (leaving headroom a future raw token could refill without
// tripping the guard); --strict forbids that slack, so every bypass category is
// strictly monotonic-down and NO new raw token can land anywhere — including in a
// file that still carries pre-existing debt. Improve → re-baseline → commit.
//
// Phase B: ratchet mode (this file). Phase G: delete the baseline → zero tolerance
// (except `stylePxUnit`, which stays ratchet-only per §12.4).
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from "node:fs";
import { join, extname, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC_DIR = join(__dirname, "..", "src");
const BASELINE = join(__dirname, "design-debt-baseline.json");
// Declared homes for literal hex in .ts (§12.3). Excluded from the `tsHex`
// category because their CONSUMER cannot resolve a CSS custom property, so a
// `var(--color-*)` is impossible there — the hex is the only option:
// • ECharts option objects / canvas 2D contexts (dashboard chart converters)
// • user-editable chart-template source strings (customChartTemplates/*)
// • Monaco editor theme + language token colours (vrlLanguage*)
// • SVG/canvas trace-tree rendering
// • the theme source-of-truth itself (theme.ts defines the runtime palette)
// • JS that builds styled log/status HTML from a fixed status→colour map
// Entries ending in "/" match by directory prefix; others by exact suffix.
const TS_HEX_ALLOWLIST = [
// categorical / series palettes + theme source
"utils/dashboard/colorPalette.ts",
"constants/themes.ts",
"utils/traces/traceColors.ts",
"utils/theme.ts",
"utils/themeManager.ts",
"utils/chartTheme.ts", // sanctioned seam: light-theme fallback constants for jsdom/pre-CSS
// ECharts / canvas / geo chart builders — options are serialised, no var() resolves
"utils/dashboard/",
"composables/dashboard/",
"components/dashboards/addPanel/customChartExamples/",
"utils/traces/convertTraceData.ts",
"utils/traces/treeVisualizationEngine.ts",
"composables/useServiceGraphTree.ts",
"composables/useMetricsExplorer.ts",
// Monaco editor language/theme definitions (colour strings the editor lib reads)
"utils/query/vrlLanguage.ts",
"utils/query/vrlLanguageDefinition.ts",
// JS that builds styled HTML/canvas from a fixed status→colour map
"utils/logs/convertLogData.ts",
"utils/logs/statusParser.ts",
"utils/logs/keyValueParser.ts",
// LLM / metric / latency chart-panel series palettes (ECharts, no var())
"plugins/traces/llmTrendPanel.utils.ts",
"plugins/traces/config/",
"enterprise/components/billings/usageDailyPanelSchema.ts",
"composables/useLatencyInsightsDashboard.ts",
"composables/useMetricsCorrelationDashboard.ts",
"utils/metrics/metricPalette.ts",
"utils/traces/treeTooltipHelpers.ts",
"utils/alerts/alertChartData.ts",
"composables/useAlertForm.ts", // ECharts markLine colour
// Ingestion vendor brand accents (D12-style fixed brand colour; `tone` is a
// reserved-future field that is never read — see setup-card content types)
"components/ingestion/setupCard/content/",
"components/ingestion/ai/content/",
];
// The two sanctioned dark-mode seams (§3.R.1) — the only files allowed to read
// theme / declare an isDark flag, so they are exempt from the darkMechanism count.
const DARK_SEAM_ALLOWLIST = ["composables/useTheme.ts", "utils/chartTheme.ts"];
// Files allowed to carry a literal font stack. Email markup renders inside a mail
// client, which can load neither our webfont nor our custom properties, so a
// system stack is the correct choice there (FONT_AUDIT.md §5.2).
const FONT_ALLOWLIST = [
"utils/prebuilt-templates/email.ts",
"utils/fonts.ts", // defines the fallback stacks the tokens mirror
];
const PALETTE = "slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose";
const PROPS = "text|bg|border|ring|fill|stroke|divide|outline|decoration|placeholder|from|via|to";
// Categories evaluated over the WHOLE file text.
const WHOLE = {
rawPalette: new RegExp(`\\b(?:dark:|hover:|focus:|group-hover:|active:)*(?:${PROPS})-(?:${PALETTE})-\\d{2,3}\\b`, "g"),
// PROJECT raw-ramp utilities (Part B, Ring 3): feature code reaching for our own
// primitives (grey-*/primary-*) instead of semantic tokens. The ramps stay
// REGISTERED while these consumers exist (un-registering would break them, and
// migrating is not value-preserving — e.g. accent flips 600→400 in dark), so
// this ratchet freezes the count: it can only shrink toward the Part-B ideal.
rawProjectRamp: new RegExp(`\\b(?:dark:|hover:|focus:|group-hover:|active:|focus-visible:)*(?:${PROPS}|ring)-(?:grey|primary)-\\d{2,3}\\b`, "g"),
hexClass: /-\[#[0-9a-fA-F]{3,8}\]/g,
inlineHexStyle: /style="[^"]*#[0-9a-fA-F]{3,8}/g,
inlineHexBind: /:style="[^"]*#[0-9a-fA-F]{3,8}/g,
themeTernary: /theme\s*===?\s*['"]dark['"]/g,
// A bare `rounded` utility is bounded by class-list separators on BOTH sides.
// Requiring a trailing separator (space/quote/backtick/EOL) skips prose in
// comments ("short, rounded, inset") and JS object keys (`rounded: "..."`).
// Retired text-colour aliases (Tier 1): text-text-primary → text-text-heading,
// text-text-caption → text-text-secondary. Baseline 0 after the codemod, so any
// reintroduction of the class utility is a regression.
retiredTextAlias: /\btext-text-(?:primary|caption)\b/g,
bareRounded: /(?:^|[\s"'`])rounded(?=[\s"'`]|$)/gm,
// Retired radius tiers: the app exposes exactly two corner radii —
// rounded-default (controls) and rounded-surface (large surfaces) — plus
// rounded-full. The old sm/md/lg/xl scale (and its var(--radius-*) refs) was a
// set of same-value aliases that lied about being distinct; it was repointed
// (small→default, large→surface) and the tokens deleted. Baseline 0 after the
// codemod, so any reintroduction falls back to Tailwind's stock scale and is a
// regression. Matches the class utility (incl. per-corner variants) and the var.
retiredRadiusAlias:
/\brounded(?:-(?:t|b|l|r|s|e|tl|tr|bl|br|ss|se|es|ee))?-(?:sm|md|lg|xl)\b|var\(--radius-(?:sm|md|lg|xl)\)/g,
// Arbitrary radius VALUE — but not a CSS keyword (`rounded-[inherit]` etc.),
// which is not a hardcoded dimension and has no scale-token equivalent.
arbRadius: /rounded(?:-[a-z]+)*-\[(?!(?:inherit|initial|unset|revert|revert-layer)\])[^\]]+\]/g,
arbTextSize: /text-\[[0-9.]+(?:px|rem)\]/g,
unscopedStyle: /<style(?![^>]*\bscoped\b)[^>]*>/g,
twPrefix: /\btw:/g,
helperUtil: /\btext-weight-[a-z]+\b/g,
arbPx: /\b(?:gap|p[trblxy]?|m[trblxy]?|w|h|size|min-w|min-h|max-w|max-h|top|left|right|bottom|inset|leading)-\[[0-9.]+px\]/g,
arbZ: /\bz-\[[0-9]+\]/g,
// Literal font stacks. The app ships exactly two families, reached only via
// var(--font-sans) / var(--font-mono) (FONT_AUDIT.md). Anything else resolves
// to whatever the visitor's OS happens to have, which is how the app came to
// render in ~14 different stacks. Covers CSS declarations, Tailwind arbitrary
// values (`[font-family:…]`), and `font-[…]` when the value looks like a
// family rather than a weight (`font-[600]` stays legal).
literalFontFamily: new RegExp(
[
// font-family: <anything that isn't our token / inherit>.
// The lookahead absorbs the whitespace itself — if `\s*` were consumed
// first it could backtrack to zero width and let ` var(--font-mono)` slip
// through as a "literal" starting with a space.
String.raw`font-family:(?!\s*(?:var\(--font-(?:sans|mono)\)|inherit))\s*[^;"'\`\n}]+`,
// [font-family:<literal>]
String.raw`\[font-family:(?!(?:var\(--font-(?:sans|mono)\)|inherit)\])[^\]]+\]`,
// font-[<literal family>] — weights like font-[600] are not families
String.raw`\bfont-\[(?!\d+\]|var\(--font-(?:sans|mono)\)\]|inherit\])[^\]]*(?:mono|serif|Menlo|Monaco|Consolas|Courier|Geist|Inconsolata|Fira|Ubuntu|Segoe|Roboto|apple-system)[^\]]*\]`,
].join("|"),
"g",
),
// Dark-mode mechanism fragmentation (§3.R.2 mechanisms 1,2,5,6,7)
darkMechanism: /theme\s*[=!]==?\s*['"]dark['"]|const\s+(?:isDark|isDarkMode|darkMode)\s*=|\.body--(?:dark|light)|classList\.contains\(['"]body--|\.(?:light|dark)-mode\b/g,
};
// Categories evaluated ONLY inside <style>…</style> blocks (scoped AND unscoped —
// styleBlocks() collects every block regardless of the `scoped` attribute).
const STYLE_ONLY = {
styleBlockHex: /#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(/g,
stylePxUnit: /\b(?:[2-9]|[0-9]{2,})(?:\.[0-9]+)?px\b/g, // 1px hairlines exempt
// Raw `var(--color-*)` inside a component style block (F.6). The consumption
// ladder wants colours reached via a registered utility, not a raw var() in a
// <style> block; ~84% of these already have a utility (AUDIT §7). Ratcheted so
// the count can only shrink — the biggest previously-unmeasured debt surface.
rawVarInComponent: /var\(\s*--color-[a-z0-9-]+/g,
};
function walk(dir, files = []) {
for (const entry of readdirSync(dir)) {
if (entry === "node_modules" || entry === "dist") continue;
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) walk(full, files);
else if (extname(entry) === ".vue" || extname(entry) === ".ts") files.push(full);
}
return files;
}
function styleBlocks(text) {
const out = [];
const re = /<style[^>]*>([\s\S]*?)<\/style>/g;
let m;
while ((m = re.exec(text)) !== null) out.push(m[1]);
return out.join("\n");
}
// Every surviving <style> block must justify its own existence: it opens with
// /* keep(<tag>): <one line why this cannot be Tailwind> */
// Both `keep(<tag>)` and `keep: <tag>` are accepted — the punctuation is not the
// point, the stated reason is. Sanctioned tags: O2_STYLE_MIGRATION_PLAN.md §3.1
// step 5. `lib-override:<lib>` takes a free-form library suffix, matched by prefix.
const KEEP_TAG_NAMES =
"lib-override(?::[\\w.-]+)?|generated-content|keyframes|print|scrollbar|complex-state|brand|third-party";
const KEEP_TAGS = new RegExp(`keep\\s*(?:\\(\\s*(?:${KEEP_TAG_NAMES})\\s*\\)|:\\s*(?:${KEEP_TAG_NAMES}))`);
// Counts style blocks that carry no keep-comment. A block with no justification
// is debt by definition — either it should have been migrated to utilities, or
// its author owes one line saying why it could not be.
function countUnjustifiedBlocks(text) {
let n = 0;
const re = /<style[^>]*>([\s\S]*?)<\/style>/g;
let m;
while ((m = re.exec(text)) !== null) {
if (!KEEP_TAGS.test(m[1])) n++;
}
return n;
}
function countFile(file, rel) {
const text = readFileSync(file, "utf8");
const counts = {};
const isVue = rel.endsWith(".vue");
const isSpec = rel.includes(".spec.");
if (isVue) {
for (const [k, re] of Object.entries(WHOLE)) {
const n = (text.match(re) || []).length;
if (n) counts[k] = n;
}
const sb = styleBlocks(text);
for (const [k, re] of Object.entries(STYLE_ONLY)) {
const n = (sb.match(re) || []).length;
if (n) counts[k] = n;
}
const unjustified = countUnjustifiedBlocks(text);
if (unjustified) counts.styleKeepComment = unjustified;
} else {
// .ts — only tsHex (non-spec, non-allowlisted) and darkMechanism apply.
// Allowlist entry ending in "/" matches by directory prefix, else by suffix.
const allowed = TS_HEX_ALLOWLIST.some((p) =>
p.endsWith("/") ? rel.includes(p) : rel.endsWith(p),
);
if (!isSpec && !allowed) {
// Strip comments first so a hex mentioned in prose ("e.g. #FF0000")
// doesn't count as a real colour literal.
const code = text
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:])\/\/[^\n]*/g, "$1");
const n = (code.match(/['"]#[0-9a-fA-F]{3,8}['"]/g) || []).length;
if (n) counts.tsHex = n;
}
if (!isSpec && !DARK_SEAM_ALLOWLIST.some((p) => rel.endsWith(p))) {
const n = (text.match(WHOLE.darkMechanism) || []).length;
if (n) counts.darkMechanism = n;
}
// Literal font stacks also leak in via .ts that builds HTML/CSS strings
// (trace tooltips, chart tooltips) — that surface was ~20 of the original hits.
if (!isSpec && !FONT_ALLOWLIST.some((p) => rel.endsWith(p))) {
const n = (text.match(WHOLE.literalFontFamily) || []).length;
if (n) counts.literalFontFamily = n;
}
}
return counts;
}
const files = walk(SRC_DIR);
const current = {};
for (const f of files) {
const rel = relative(SRC_DIR, f).split("\\").join("/");
const c = countFile(f, rel);
if (Object.keys(c).length) current[rel] = c;
}
if (process.argv.includes("--baseline")) {
const totals = {};
for (const c of Object.values(current))
for (const [k, n] of Object.entries(c)) totals[k] = (totals[k] || 0) + n;
writeFileSync(BASELINE, JSON.stringify({ totals, files: current }, null, 2) + "\n");
console.log(`Wrote baseline: ${Object.keys(current).length} files with debt.`);
console.log("Totals:", JSON.stringify(totals));
process.exit(0);
}
if (!existsSync(BASELINE)) {
console.error("No baseline found. Run: node scripts/check-design-consistency.mjs --baseline");
process.exit(2);
}
const baseline = JSON.parse(readFileSync(BASELINE, "utf8"));
const base = baseline.files || {};
const regressions = [];
let improved = 0;
for (const [rel, c] of Object.entries(current)) {
for (const [k, n] of Object.entries(c)) {
const b = (base[rel] && base[rel][k]) || 0;
if (n > b) regressions.push({ rel, k, n, b });
}
}
// count improvements (any file/category strictly below baseline, or dropped to 0)
// and record them: in --strict mode a below-baseline count is a STALE baseline,
// i.e. slack the ratchet is leaving open. That slack is exactly where a future
// raw token would land WITHOUT tripping the guard (a file at baseline 5 that has
// since dropped to 2 would silently accept 3 new raw var()s). Closing it makes
// every category monotonically shrink and forbids ANY new raw-token bypass —
// even inside a file that still carries pre-existing debt.
const stale = [];
for (const [rel, bc] of Object.entries(base)) {
for (const [k, b] of Object.entries(bc)) {
const n = (current[rel] && current[rel][k]) || 0;
if (n < b) {
improved += b - n;
stale.push({ rel, k, n, b });
}
}
}
// --strict (used by CI): the baseline must equal reality — no headroom. Passed
// by `lint:design:strict` in build-pr-image.yml / playwright.yml.
const STRICT = process.argv.includes("--strict");
if (regressions.length) {
console.error(`\ncheck-design-consistency: ${regressions.length} regression(s) above baseline:\n`);
for (const { rel, k, n, b } of regressions.slice(0, 60))
console.error(` ${rel} [${k}] ${b} → ${n} (+${n - b})`);
if (regressions.length > 60) console.error(` …and ${regressions.length - 60} more`);
console.error(`\nUse the sanctioned utilities/tokens (§4) instead. See DESIGN_TOKEN_STANDARD.md.\n`);
process.exit(1);
}
if (STRICT && stale.length) {
console.error(
`\ncheck-design-consistency (--strict): baseline is STALE — ${stale.length} file/category ` +
`pair(s) are below their recorded debt, i.e. ${improved} occurrence(s) of slack the\n` +
`ratchet would let a future raw token silently refill. Lock the win in:\n`,
);
for (const { rel, k, n, b } of stale.slice(0, 60))
console.error(` ${rel} [${k}] ${b} → ${n} (−${b - n})`);
if (stale.length > 60) console.error(` …and ${stale.length - 60} more`);
console.error(
`\nRun: node scripts/check-design-consistency.mjs --baseline and commit the tightened baseline.\n`,
);
process.exit(1);
}
console.log(`OK — no design-consistency regressions (${Object.keys(current).length} files scanned).`);
if (improved)
console.log(
`Improved by ${improved} occurrence(s) below baseline — re-run with --baseline and commit the update` +
(STRICT ? "" : " (required in --strict / CI)") + ".",
);
+221
View File
@@ -0,0 +1,221 @@
#!/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.`);
}
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env node
// Copyright 2026 OpenObserve Inc.
//
// Prove a CSS refactor is a no-op at the BUILT stylesheet level
// (O2_STYLE_MIGRATION_PLAN.md §7.2). Emits a stable, sorted, diffable summary of
// every selector in a built CSS bundle plus a hash of its declarations, so
// `diff before.txt after.txt` shows exactly what changed — and nothing else.
//
// Declarations are normalised (whitespace collapsed, declarations sorted) so a
// pure MOVE between files/layers hashes identically and produces no diff noise.
//
// Usage:
// npm run build-only
// node scripts/css-selector-diff.mjs dist/assets/*.css > /tmp/css-before.txt
// # ...on the migration branch...
// node scripts/css-selector-diff.mjs dist/assets/*.css > /tmp/css-after.txt
// diff /tmp/css-before.txt /tmp/css-after.txt
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
const files = process.argv.slice(2).filter((a) => !a.startsWith("--"));
if (files.length === 0) {
console.error("Usage: node scripts/css-selector-diff.mjs <built.css>...");
process.exit(2);
}
function stripComments(css) {
return css.replace(/\/\*[\s\S]*?\*\//g, "");
}
/**
* Normalise a declaration body: sort declarations, collapse whitespace.
* Makes a moved rule hash identically to its original.
*/
function normaliseBody(body) {
return body
.split(";")
.map((d) => d.replace(/\s+/g, " ").trim())
.filter(Boolean)
.sort()
.join(";");
}
function hash(s) {
return createHash("sha1").update(s).digest("hex").slice(0, 10);
}
/**
* Recursively collect rules, tracking at-rule context (@media/@layer/@supports)
* so a rule that changes layer shows up as a changed context.
*/
function collect(css, context, 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).replace(/\s+/g, " ").trim();
const body = css.slice(braceStart + 1, i);
if (prelude.startsWith("@")) {
const at = prelude.split(/\s|\(/)[0].toLowerCase();
if (at === "@media" || at === "@layer" || at === "@supports" || at === "@container") {
// nested context — recurse
collect(body, context ? `${context} > ${prelude}` : prelude, out);
} else {
// @keyframes / @font-face / @theme — hash body as a unit
out.push({ context, selector: prelude, hash: hash(body.replace(/\s+/g, " ").trim()) });
}
} else if (prelude) {
// Split grouped selectors so reordering a group isn't a false diff.
const norm = normaliseBody(body);
for (const sel of prelude.split(",").map((s) => s.replace(/\s+/g, " ").trim()).filter(Boolean)) {
out.push({ context, selector: sel, hash: hash(norm) });
}
}
start = i + 1;
}
} else if (ch === ";" && depth === 0) {
start = i + 1;
}
}
}
const rules = [];
for (const file of files) {
collect(stripComments(readFileSync(file, "utf8")), "", rules);
}
// Sort for a stable diff; dedupe identical (context, selector, hash) triples.
const seen = new Set();
const lines = [];
for (const r of rules) {
const key = `${r.context}|${r.selector}|${r.hash}`;
if (seen.has(key)) continue;
seen.add(key);
lines.push(`${r.context ? r.context + " :: " : ""}${r.selector} #${r.hash}`);
}
lines.sort();
console.log(lines.join("\n"));
console.error(`css-selector-diff: ${lines.length} unique rules from ${files.length} file(s).`);
+564
View File
@@ -0,0 +1,564 @@
{
"totals": {
"rawProjectRamp": 150,
"rawVarInComponent": 615,
"arbZ": 40,
"darkMechanism": 20,
"unscopedStyle": 8,
"stylePxUnit": 90
},
"files": {
"components/CodeQueryEditor.vue": {
"rawProjectRamp": 1,
"rawVarInComponent": 2
},
"components/CommunitySlackInvite.vue": {
"rawProjectRamp": 6
},
"components/CustomDateTimePicker.vue": {
"arbZ": 1
},
"components/EnterpriseUpgradeDialog.vue": {
"arbZ": 1
},
"components/Header.vue": {
"rawVarInComponent": 2
},
"components/MenuLink.vue": {
"rawProjectRamp": 10
},
"components/O2AIChat.vue": {
"rawVarInComponent": 50
},
"components/OrganizationSelector.vue": {
"rawProjectRamp": 3,
"rawVarInComponent": 2
},
"components/RichTextInput.vue": {
"arbZ": 1,
"rawVarInComponent": 18
},
"components/TenstackTable.vue": {
"rawProjectRamp": 1,
"rawVarInComponent": 3
},
"components/ThemeSwitcher.vue": {
"darkMechanism": 1
},
"components/WebinarBanner.vue": {
"arbZ": 1
},
"components/ai-assistant/welcome/CapabilityCards.vue": {
"arbZ": 4
},
"components/alerts/AddAlert.vue": {
"rawVarInComponent": 1
},
"components/alerts/AlertHistoryTimeline.vue": {
"rawVarInComponent": 7
},
"components/alerts/AlertInsightsContextMenu.vue": {
"rawVarInComponent": 2
},
"components/alerts/AlertSettingsHelpDrawer.vue": {
"rawVarInComponent": 49
},
"components/alerts/AlertSummary.vue": {
"arbZ": 1,
"rawVarInComponent": 14
},
"components/alerts/DestinationPreview.vue": {
"rawVarInComponent": 7
},
"components/alerts/FilterGroup.vue": {
"rawVarInComponent": 4
},
"components/alerts/ImportSemanticGroups.vue": {
"rawVarInComponent": 5
},
"components/alerts/IncidentRCAAnalysis.vue": {
"rawVarInComponent": 36
},
"components/alerts/IncidentServiceGraph.vue": {
"rawProjectRamp": 3
},
"components/alerts/PrebuiltDestinationSelector.vue": {
"arbZ": 2
},
"components/alerts/QueryEditorDialog.vue": {
"arbZ": 2
},
"components/alerts/SemanticFieldGroupsConfig.vue": {
"rawVarInComponent": 1
},
"components/alerts/steps/QueryConfig.vue": {
"rawProjectRamp": 2
},
"components/anomaly_detection/AnomalySummary.vue": {
"rawVarInComponent": 7
},
"components/common/BaseImport.vue": {
"unscopedStyle": 1
},
"components/common/LoadingProgress.vue": {
"rawProjectRamp": 2,
"arbZ": 1
},
"components/common/SectionHub.vue": {
"rawProjectRamp": 5
},
"components/common/empty-states/QuickStartCard.vue": {
"rawProjectRamp": 4
},
"components/dashboards/AddDashboardFromGitHub.vue": {
"rawProjectRamp": 1
},
"components/dashboards/ColorSwatchPicker.vue": {
"rawProjectRamp": 3
},
"components/dashboards/ColumnFormatControls.vue": {
"rawProjectRamp": 2
},
"components/dashboards/OverrideConfigPopup.vue": {
"rawProjectRamp": 2
},
"components/dashboards/QueryInspector.vue": {
"rawVarInComponent": 4
},
"components/dashboards/addPanel/DrilldownUserGuide.vue": {
"rawVarInComponent": 2
},
"components/dashboards/panels/HTMLRenderer.vue": {
"unscopedStyle": 2
},
"components/dashboards/panels/TableRenderer.vue": {
"rawVarInComponent": 8
},
"components/functions/FunctionList.vue": {
"rawVarInComponent": 2
},
"components/ingestion/ai/content/AIIntegrationCard.vue": {
"rawVarInComponent": 2
},
"components/ingestion/setupCard/SetupCardRenderer.vue": {
"rawVarInComponent": 17
},
"components/logstream/schema.vue": {
"rawVarInComponent": 2
},
"components/pipeline/NodeForm/AssociateFunction.vue": {
"rawProjectRamp": 2,
"rawVarInComponent": 7
},
"components/pipeline/NodeForm/CreateDestinationForm.vue": {
"arbZ": 1
},
"components/pipeline/NodeSidebar.vue": {
"unscopedStyle": 1,
"rawVarInComponent": 21
},
"components/pipeline/PipelineEditor.vue": {
"unscopedStyle": 1,
"rawVarInComponent": 20
},
"components/pipeline/PipelineSectionTabs.vue": {
"rawProjectRamp": 2
},
"components/pipeline/PipelineView.vue": {
"stylePxUnit": 6,
"rawVarInComponent": 15
},
"components/rum/PlayerTracesTab.vue": {
"rawVarInComponent": 1
},
"components/rum/errorTracking/view/ErrorEvents.vue": {
"rawVarInComponent": 7
},
"components/settings/License.vue": {
"rawVarInComponent": 4
},
"components/settings/ModelPricingList.vue": {
"arbZ": 1
},
"components/settings/Nodes.vue": {
"rawVarInComponent": 6
},
"components/settings/OrgStorageEditor.vue": {
"arbZ": 1
},
"components/synthetics/CreateBrowserTestSkeleton.vue": {
"rawProjectRamp": 1
},
"components/synthetics/configure/CheckAlerts.vue": {
"rawProjectRamp": 1
},
"components/synthetics/configure/CheckAuthNetwork.vue": {
"rawProjectRamp": 1
},
"components/synthetics/configure/CheckBrowserDevices.vue": {
"rawProjectRamp": 1
},
"components/synthetics/configure/CheckCapture.vue": {
"rawProjectRamp": 1
},
"components/synthetics/configure/CheckDetails.vue": {
"rawProjectRamp": 1
},
"components/synthetics/configure/CheckLocations.vue": {
"rawProjectRamp": 1
},
"components/synthetics/configure/CheckSchedule.vue": {
"rawProjectRamp": 1
},
"components/synthetics/configure/types/CheckHttpConfig.vue": {
"rawProjectRamp": 1
},
"components/synthetics/configure/types/CheckSshConfig.vue": {
"rawProjectRamp": 1
},
"components/synthetics/configure/types/CheckTcpConfig.vue": {
"rawProjectRamp": 1
},
"components/synthetics/configure/types/CheckTlsConfig.vue": {
"rawProjectRamp": 1
},
"components/synthetics/journey/BrowserJourney.vue": {
"rawProjectRamp": 1
},
"components/synthetics/journey/BrowserJourneyStep.vue": {
"rawProjectRamp": 3
},
"components/synthetics/journey/JourneySteps.vue": {
"rawProjectRamp": 3
},
"components/synthetics/results/ProtocolRunSummary.vue": {
"rawProjectRamp": 6
},
"components/traces/FlameGraphView.vue": {
"rawProjectRamp": 1
},
"enterprise/components/billings/BillingGroup.vue": {
"rawProjectRamp": 5
},
"enterprise/components/billings/plans.vue": {
"rawProjectRamp": 1
},
"enterprise/components/onlineEvals/forms/EvalJobDetail.vue": {
"rawProjectRamp": 1
},
"enterprise/components/onlineEvals/forms/ScoreConfigDetail.vue": {
"rawVarInComponent": 4
},
"enterprise/components/onlineEvals/forms/ScoreConfigDialog.vue": {
"rawProjectRamp": 5,
"rawVarInComponent": 6
},
"enterprise/components/onlineEvals/forms/ScorerDetail.vue": {
"rawVarInComponent": 47
},
"enterprise/components/onlineEvals/forms/ScorerFormPage.vue": {
"rawProjectRamp": 4,
"rawVarInComponent": 1
},
"enterprise/components/onlineEvals/forms/job/JobFilterBuilder.vue": {
"rawVarInComponent": 2
},
"enterprise/components/onlineEvals/forms/job/JobInputMapping.vue": {
"rawProjectRamp": 1
},
"enterprise/components/onlineEvals/forms/scorer/ScorerTestPanel.vue": {
"rawProjectRamp": 1
},
"lib/core/Button/OButton.vue": {
"rawProjectRamp": 4
},
"lib/core/Code/OCode.vue": {
"rawProjectRamp": 2
},
"lib/core/Code/OCodeBlock.vue": {
"rawVarInComponent": 13
},
"lib/core/EmptyState/EmptyStateActionCard.vue": {
"rawProjectRamp": 4
},
"lib/core/EmptyState/EmptyStateIngestionCard.vue": {
"rawProjectRamp": 3,
"rawVarInComponent": 12
},
"lib/core/EmptyState/EmptyStateIngestionChip.vue": {
"rawProjectRamp": 3,
"rawVarInComponent": 5
},
"lib/core/EmptyState/illustrations/EmptyAlert.vue": {
"stylePxUnit": 2
},
"lib/core/EmptyState/illustrations/EmptyAllClear.vue": {
"stylePxUnit": 4
},
"lib/core/EmptyState/illustrations/EmptyBoard.vue": {
"stylePxUnit": 1
},
"lib/core/EmptyState/illustrations/EmptyBrokenPanel.vue": {
"stylePxUnit": 3
},
"lib/core/EmptyState/illustrations/EmptyBuilder.vue": {
"stylePxUnit": 12
},
"lib/core/EmptyState/illustrations/EmptyConnect.vue": {
"stylePxUnit": 15
},
"lib/core/EmptyState/illustrations/EmptyError.vue": {
"stylePxUnit": 8
},
"lib/core/EmptyState/illustrations/EmptyExplorer.vue": {
"stylePxUnit": 7
},
"lib/core/EmptyState/illustrations/EmptyFunction.vue": {
"stylePxUnit": 5
},
"lib/core/EmptyState/illustrations/EmptyHistory.vue": {
"stylePxUnit": 4
},
"lib/core/EmptyState/illustrations/EmptyHourglass.vue": {
"stylePxUnit": 1
},
"lib/core/EmptyState/illustrations/EmptyPipeline.vue": {
"stylePxUnit": 2
},
"lib/core/EmptyState/illustrations/EmptyReport.vue": {
"stylePxUnit": 3
},
"lib/core/EmptyState/illustrations/EmptySchedule.vue": {
"stylePxUnit": 2
},
"lib/core/EmptyState/illustrations/EmptyServiceGraph.vue": {
"stylePxUnit": 12
},
"lib/core/EmptyState/illustrations/EmptyTrace.vue": {
"stylePxUnit": 1
},
"lib/core/EmptyState/illustrations/EmptyWaiting.vue": {
"stylePxUnit": 2
},
"lib/core/Navbar/ONavGroup.vue": {
"rawProjectRamp": 1
},
"lib/core/Navbar/ONavbar.vue": {
"rawProjectRamp": 2,
"rawVarInComponent": 1
},
"lib/core/PageHeader/OPageHeader.vue": {
"rawProjectRamp": 1
},
"lib/core/Splitter/OSplitter.vue": {
"rawVarInComponent": 1
},
"lib/core/Table/OTable.vue": {
"rawVarInComponent": 1
},
"lib/core/Table/sub-components/OTableBody.vue": {
"rawVarInComponent": 2
},
"lib/core/Table/sub-components/OTableBodyCell.vue": {
"rawVarInComponent": 3
},
"lib/core/Table/sub-components/OTableBodyRow.vue": {
"rawVarInComponent": 1
},
"lib/core/Table/sub-components/OTableColumnToggle.vue": {
"rawProjectRamp": 1
},
"lib/feedback/Skeleton/OSkeleton.vue": {
"rawVarInComponent": 1
},
"lib/forms/Color/OColor.vue": {
"arbZ": 1
},
"lib/forms/Date/ODate.vue": {
"rawProjectRamp": 1
},
"lib/forms/DateTimeRange/ODateRangeCalendar.vue": {
"rawVarInComponent": 2
},
"lib/forms/Input/OInput.vue": {
"rawProjectRamp": 1
},
"lib/forms/Select/OSelect.vue": {
"rawProjectRamp": 4,
"arbZ": 2
},
"lib/forms/Switch/OSwitch.vue": {
"rawProjectRamp": 2
},
"lib/forms/Time/OTime.vue": {
"rawProjectRamp": 1
},
"lib/lists/FieldList/OFieldList.vue": {
"arbZ": 2,
"rawVarInComponent": 1
},
"lib/navigation/Tabs/OTab.vue": {
"rawProjectRamp": 1
},
"lib/overlay/Dropdown/ODropdown.vue": {
"arbZ": 1
},
"lib/overlay/Tooltip/OTooltip.vue": {
"arbZ": 1
},
"plugins/logs/DetailTable.vue": {
"rawVarInComponent": 2
},
"plugins/logs/IndexList.vue": {
"rawVarInComponent": 1
},
"plugins/logs/SearchBar.vue": {
"arbZ": 4,
"rawVarInComponent": 1
},
"plugins/logs/SearchResult.vue": {
"rawVarInComponent": 19
},
"plugins/logs/TenstackTable.vue": {
"rawVarInComponent": 5
},
"plugins/logs/patterns/PatternList.vue": {
"rawVarInComponent": 6
},
"plugins/metrics/explorer/FunctionConfigDialog.vue": {
"rawProjectRamp": 2
},
"plugins/pipelines/CustomNode.vue": {
"arbZ": 7,
"rawVarInComponent": 10
},
"plugins/pipelines/PipelineFlow.vue": {
"arbZ": 1
},
"plugins/traces/Index.vue": {
"rawVarInComponent": 2
},
"plugins/traces/IndexList.vue": {
"rawVarInComponent": 2
},
"plugins/traces/LLMContentRenderer.vue": {
"rawVarInComponent": 7
},
"plugins/traces/LLMInsightsSkeleton.vue": {
"rawVarInComponent": 3
},
"plugins/traces/SearchBar.vue": {
"arbZ": 1
},
"plugins/traces/SearchResult.vue": {
"rawVarInComponent": 1
},
"plugins/traces/ServiceGraphNodeSidePanel.vue": {
"rawVarInComponent": 9
},
"plugins/traces/SessionDetails.vue": {
"rawProjectRamp": 1,
"arbZ": 1,
"rawVarInComponent": 9
},
"plugins/traces/ThreadToolCalls.vue": {
"rawVarInComponent": 30
},
"plugins/traces/ThreadView.vue": {
"rawVarInComponent": 9
},
"plugins/traces/TraceDAG.vue": {
"rawVarInComponent": 1
},
"plugins/traces/TraceDetails.vue": {
"rawVarInComponent": 2
},
"plugins/traces/TraceDetailsSidebar.vue": {
"rawVarInComponent": 17
},
"plugins/traces/TraceTree.vue": {
"rawVarInComponent": 2
},
"plugins/traces/components/TraceErrorTab.vue": {
"rawVarInComponent": 14
},
"plugins/traces/metrics/TracesAnalysisDashboard.vue": {
"rawVarInComponent": 1
},
"plugins/traces/metrics/TracesMetricsContextMenu.vue": {
"arbZ": 1
},
"utils/dashboard/colorPalette.ts": {
"darkMechanism": 5
},
"utils/dashboard/convertPromQLData.ts": {
"darkMechanism": 2
},
"utils/dashboard/sql/charts/convertSQLHeatmapChart.ts": {
"darkMechanism": 1
},
"utils/dashboard/sql/charts/convertSQLLineAreaChart.ts": {
"darkMechanism": 1
},
"utils/dashboard/sql/charts/convertSQLMetricChart.ts": {
"darkMechanism": 2
},
"utils/dashboard/sql/charts/convertSQLStackedChart.ts": {
"darkMechanism": 1
},
"utils/dashboard/sql/shared/contextBuilder.ts": {
"darkMechanism": 1
},
"utils/dashboard/sqlTimeSeriesConverter.ts": {
"darkMechanism": 2
},
"utils/logs/convertLogData.ts": {
"darkMechanism": 1
},
"utils/themeManager.ts": {
"darkMechanism": 1
},
"utils/traces/traceColors.ts": {
"darkMechanism": 2
},
"views/Dashboards/DashboardSettings.vue": {
"rawVarInComponent": 3
},
"views/Dashboards/Dashboards.vue": {
"rawProjectRamp": 1
},
"views/Dashboards/ImportDashboard.vue": {
"unscopedStyle": 1
},
"views/Dashboards/RenderDashboardCharts.vue": {
"unscopedStyle": 1,
"rawVarInComponent": 4
},
"views/Dashboards/ScheduledDashboards.vue": {
"rawVarInComponent": 5
},
"views/Dashboards/ViewDashboard.vue": {
"unscopedStyle": 1,
"rawVarInComponent": 1
},
"views/HomeChatHistory.vue": {
"rawProjectRamp": 1
},
"views/HomeView.vue": {
"rawVarInComponent": 18
},
"views/OverviewTab.vue": {
"rawProjectRamp": 14,
"arbZ": 1
},
"views/synthetics/CreateBrowserTest.vue": {
"rawProjectRamp": 3
},
"views/synthetics/MonitorRuns.vue": {
"rawProjectRamp": 3
},
"views/synthetics/RunDetail.vue": {
"rawProjectRamp": 1
}
}
}
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env node
// Copyright 2026 OpenObserve Inc.
//
// Pre-flight before deleting any CSS class rule (O2_STYLE_MIGRATION_PLAN.md §7.3).
// Answers: "who actually uses this class?" — covering the ways a class name can
// reach the DOM and escape a naive template grep (plan risks R1 / R5):
//
// 1. static class="… foo …"
// 2. bound :class="{ foo: cond }" / :class="['foo']" / :class="x ? 'foo' : ''"
// 3. JS classList.add('foo'), `status-${x}` fragments, string literals in .ts
// 4. CSS the rules that DEFINE it (so you can see every definition site)
// 5. specs assertions on the class (these break when you delete it)
//
// Usage:
// node scripts/find-class-consumers.mjs logPage_bkcss histogram-container
// node scripts/find-class-consumers.mjs --quiet foo # exit 1 if consumers exist
//
// Verdict per class:
// NO CONSUMERS → safe to delete
// SELF-CONTAINED → only the defining file uses it → colocate
// HAS CONSUMERS → migrate consumers in the same PR
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, extname, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC_DIR = join(__dirname, "..", "src");
const EXTS = new Set([".css", ".vue", ".ts", ".js", ".scss"]);
const args = process.argv.slice(2);
const quiet = args.includes("--quiet");
const names = args.filter((a) => !a.startsWith("--"));
if (names.length === 0) {
console.error("Usage: node scripts/find-class-consumers.mjs <className>...");
process.exit(2);
}
function walk(dir, files = []) {
for (const entry of readdirSync(dir)) {
if (entry === "node_modules" || entry === "dist") continue;
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) walk(full, files);
else if (EXTS.has(extname(entry))) files.push(full);
}
return files;
}
const files = walk(SRC_DIR);
const cache = new Map();
for (const f of files) cache.set(f, readFileSync(f, "utf8").split("\n"));
const rel = (f) => f.replace(SRC_DIR + "/", "src/");
/**
* Split a .vue file into template / script / style line ranges so we can say
* *where* a hit lives — a hit in <style> is a definition, in <template> a consumer.
*/
function sectionOf(file, lines, lineIdx) {
if (extname(file) !== ".vue") return extname(file) === ".css" || extname(file) === ".scss" ? "style" : "script";
let section = "script";
for (let i = 0; i <= lineIdx; i++) {
const l = lines[i];
if (/^\s*<template[\s>]/i.test(l)) section = "template";
else if (/^\s*<script[\s>]/i.test(l)) section = "script";
else if (/^\s*<style[\s>]/i.test(l)) section = "style";
}
return section;
}
let anyConsumers = false;
for (const name of names) {
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// A definition: `.name` appearing in a selector position inside CSS.
const defRe = new RegExp(`\\.${esc}(?![\\w-])`);
// A usage: the bare word inside a class-ish string context.
const wordRe = new RegExp(`(?<![\\w-])${esc}(?![\\w-])`);
// Dynamic construction: `status-${x}` style — flag prefixes of the name.
const parts = name.split("-");
const dynRes = [];
for (let i = 1; i < parts.length; i++) {
const prefix = parts.slice(0, i).join("-");
dynRes.push(new RegExp("[\"'`]" + prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "-\\$\\{"));
}
const definitions = [];
const consumers = [];
const specs = [];
const dynamic = [];
for (const [file, lines] of cache) {
lines.forEach((line, i) => {
const section = sectionOf(file, lines, i);
const isSpec = /\.spec\.(ts|js)$/.test(file);
if (wordRe.test(line)) {
const hit = { file: rel(file), line: i + 1, text: line.trim().slice(0, 100) };
if (section === "style" && defRe.test(line)) definitions.push(hit);
else if (isSpec) specs.push(hit);
else if (section === "template" || section === "script") consumers.push(hit);
else if (defRe.test(line)) definitions.push(hit);
}
for (const dre of dynRes) {
if (dre.test(line)) dynamic.push({ file: rel(file), line: i + 1, text: line.trim().slice(0, 100) });
}
});
}
const defFiles = new Set(definitions.map((d) => d.file));
const consumerFiles = new Set(consumers.map((c) => c.file));
const external = [...consumerFiles].filter((f) => !defFiles.has(f));
let verdict;
if (consumers.length === 0 && specs.length === 0) verdict = "NO CONSUMERS — safe to delete";
else if (external.length === 0) verdict = "SELF-CONTAINED — colocate into the defining file";
else verdict = `HAS CONSUMERS (${external.length} external file(s)) — migrate them in the same PR`;
if (verdict.startsWith("HAS")) anyConsumers = true;
if (quiet) continue;
console.log(`\n\x1b[1m.${name}\x1b[0m — ${verdict}`);
const show = (label, arr) => {
if (arr.length === 0) return;
console.log(` ${label} (${arr.length}):`);
for (const h of arr.slice(0, 12)) console.log(` ${h.file}:${h.line} ${h.text}`);
if (arr.length > 12) console.log(` ...and ${arr.length - 12} more`);
};
show("definitions", definitions);
show("consumers", consumers);
show("specs (update in-PR)", specs);
if (dynamic.length > 0) {
console.log(` \x1b[33mdynamic-construction candidates\x1b[0m (${dynamic.length}) — review manually:`);
for (const h of dynamic.slice(0, 6)) console.log(` ${h.file}:${h.line} ${h.text}`);
}
}
process.exit(quiet && anyConsumers ? 1 : 0);
+179
View File
@@ -0,0 +1,179 @@
{
"_comment": "Phase 1 migration map: --o2-* -> --color-*. All targets are real --color-* tokens already defined in the token files. Consumed by codemod-o2-refs.mjs. Phase 2 (literal-owner families) appends to `migrate` as each family gets a --color-* token.",
"migrate": {
"--o2-primary-background": "--color-surface-base",
"--o2-secondary-background": "--color-surface-panel",
"--o2-muted-background": "--color-surface-subtle",
"--o2-text-heading": "--color-text-heading",
"--o2-text-primary": "--color-text-heading",
"--o2-text-body": "--color-text-body",
"--o2-text-secondary": "--color-text-secondary",
"--o2-text-caption": "--color-text-secondary",
"--o2-text-label": "--color-text-label",
"--o2-text-muted": "--color-text-muted",
"--o2-text-placeholder": "--color-text-placeholder",
"--o2-text-link": "--color-text-link",
"--o2-text-link-hover": "--color-text-link-hover",
"--o2-text-code": "--color-text-code",
"--o2-text-inverse": "--color-text-inverse",
"--o2-text-4": "--color-text-heading",
"--o2-text-2": "--color-text-secondary",
"--o2-text-1": "--color-text-secondary",
"--o2-text-3": "--color-text-label",
"--o2-card-background": "--color-surface-base",
"--o2-card-text": "--color-card-text",
"--o2-popover-background": "--color-surface-overlay",
"--o2-popover-text": "--color-text-heading",
"--o2-code-bg": "--color-code-bg",
"--o2-border": "--color-border-default",
"--o2-border-input": "--color-input-border",
"--o2-primary-foreground": "--color-button-primary-foreground",
"--o2-hover-accent": "--color-interactive-hover-bg",
"--o2-theme-color": "--color-theme-accent",
"--o2-dark-theme-color": "--color-theme-accent",
"--o2-body-primary-bg": "--color-theme-body-bg-primary",
"--o2-body-secondary-bg": "--color-theme-body-bg-secondary",
"--o2-primary-btn-bg": "--color-button-primary",
"--o2-primary-btn-text": "--color-button-primary-foreground",
"--o2-header-menu-bg": "--color-theme-header-menu-bg",
"--o2-border-2": "--color-border-subtle",
"--o2-menu-gradient-start": "--color-theme-menu-gradient-start",
"--o2-menu-gradient-end": "--color-theme-menu-gradient-end",
"--o2-menu-color": "--color-theme-menu-color",
"--o2-table-header-bg": "--color-theme-table-header-bg",
"--o2-tab-bg": "--color-theme-tab-bg",
"--o2-inactive-tab-bg": "--color-theme-tab-bg-inactive",
"--o2-input-label-text-color": "--color-input-label",
"--o2-label-chip-ip-bg": "--color-label-chip-ip-bg",
"--o2-label-chip-ip-text": "--color-label-chip-ip-text",
"--o2-label-chip-ipv4-bg": "--color-label-chip-ip-bg",
"--o2-label-chip-ipv4-text": "--color-label-chip-ip-text",
"--o2-label-chip-ipv6-bg": "--color-label-chip-ip-bg",
"--o2-label-chip-ipv6-text": "--color-label-chip-ip-text",
"--o2-label-chip-method-bg": "--color-label-chip-method-bg",
"--o2-label-chip-method-text": "--color-label-chip-method-text",
"--o2-label-chip-url-bg": "--color-label-chip-url-bg",
"--o2-label-chip-url-text": "--color-label-chip-url-text",
"--o2-label-chip-num-bg": "--color-label-chip-num-bg",
"--o2-label-chip-num-text": "--color-label-chip-num-text",
"--o2-label-chip-float-bg": "--color-label-chip-float-bg",
"--o2-label-chip-float-text": "--color-label-chip-float-text",
"--o2-label-chip-hex-bg": "--color-label-chip-hex-bg",
"--o2-label-chip-hex-text": "--color-label-chip-hex-text",
"--o2-label-chip-ts-bg": "--color-label-chip-ts-bg",
"--o2-label-chip-ts-text": "--color-label-chip-ts-text",
"--o2-label-chip-date-bg": "--color-label-chip-ts-bg",
"--o2-label-chip-date-text": "--color-label-chip-ts-text",
"--o2-label-chip-time-bg": "--color-label-chip-ts-bg",
"--o2-label-chip-time-text": "--color-label-chip-ts-text",
"--o2-label-chip-id-bg": "--color-label-chip-id-bg",
"--o2-label-chip-id-text": "--color-label-chip-id-text",
"--o2-label-chip-email-bg": "--color-label-chip-email-bg",
"--o2-label-chip-email-text": "--color-label-chip-email-text",
"--o2-label-chip-str-bg": "--color-label-chip-str-bg",
"--o2-label-chip-str-text": "--color-label-chip-str-text",
"--o2-label-chip-default-bg": "--color-label-chip-default-bg",
"--o2-label-chip-default-text": "--color-label-chip-default-text",
"--o2-label-chip-pattern-bg": "--color-label-chip-pattern-bg",
"--o2-label-chip-pattern-text": "--color-label-chip-pattern-text",
"--o2-status-success-text": "--color-status-success-text",
"--o2-status-success-bg": "--color-status-success-bg",
"--o2-status-error-text": "--color-status-error-text",
"--o2-status-error-bg": "--color-status-error-bg",
"--o2-positive": "--color-status-positive",
"--o2-negative": "--color-status-negative",
"--o2-secondary-btn-bg": "--color-button-secondary",
"--o2-secondary-btn-text": "--color-button-secondary-foreground",
"--o2-secondary-btn-border": "--color-button-secondary-border",
"--o2-primary-color": "--color-accent",
"--o2-actions-column-shawdow": "--color-actions-column-shadow",
"--o2-bg-gray": "--color-bg-gray",
"--o2-border-color": "--color-card-glass-border",
"--o2-cancel-query-bg": "--color-cancel-query-bg",
"--o2-card-bg": "--color-card-glass-bg",
"--o2-card-bg-solid": "--color-card-glass-solid",
"--o2-dashboard-placeholder-bg": "--color-dashboard-placeholder-bg",
"--o2-error-tag-bg": "--color-error-tag-bg",
"--o2-error-tag-text": "--color-error-tag-text",
"--o2-field-type-boolean-bg": "--color-field-type-boolean-bg",
"--o2-field-type-boolean-text": "--color-field-type-boolean-text",
"--o2-field-type-float-bg": "--color-field-type-float-bg",
"--o2-field-type-float-text": "--color-field-type-float-text",
"--o2-field-type-number-bg": "--color-field-type-number-bg",
"--o2-field-type-number-text": "--color-field-type-number-text",
"--o2-field-type-string-bg": "--color-field-type-string-bg",
"--o2-field-type-string-text": "--color-field-type-string-text",
"--o2-field-type-timestamp-bg": "--color-field-type-timestamp-bg",
"--o2-field-type-timestamp-text": "--color-field-type-timestamp-text",
"--o2-focus-ring": "--color-focus-ring",
"--o2-gray": "--color-gray",
"--o2-hover-gray": "--color-hover-gray",
"--o2-hover-shadow": "--color-hover-shadow",
"--o2-icon-color": "--color-icon-color",
"--o2-icon-color-dark": "--color-icon-color-dark",
"--o2-info": "--color-info",
"--o2-json-boolean": "--color-json-boolean",
"--o2-json-key": "--color-json-key",
"--o2-json-null": "--color-json-null",
"--o2-json-number": "--color-json-number",
"--o2-json-object": "--color-json-object",
"--o2-json-string": "--color-json-string",
"--o2-latency-p95": "--color-latency-p95",
"--o2-log-table-header-bg": "--color-log-table-header-bg",
"--o2-log-table-row-bg": "--color-log-table-row-bg",
"--o2-log-table-row-border": "--color-log-table-row-border",
"--o2-log-table-row-hover": "--color-log-table-row-hover",
"--o2-pivot-header-border": "--color-pivot-header-border",
"--o2-section-header-bg": "--color-section-header-bg",
"--o2-service-health-critical": "--color-service-health-critical",
"--o2-service-health-degraded": "--color-service-health-degraded",
"--o2-service-health-healthy": "--color-service-health-healthy",
"--o2-service-health-warning": "--color-service-health-warning",
"--o2-severity-error-color": "--color-severity-error-color",
"--o2-severity-warning-color": "--color-severity-warning-color",
"--o2-status-info-bg": "--color-status-info-bg",
"--o2-status-info-text": "--color-status-info-text",
"--o2-status-neutral-text": "--color-status-neutral-text",
"--o2-status-warning-bg": "--color-status-warning-bg",
"--o2-status-warning-text": "--color-status-warning-text",
"--o2-sticky-col-header-bg": "--color-sticky-col-header-bg",
"--o2-tab-text-color": "--color-tab-text-color",
"--o2-table-actions-bg": "--color-table-actions-bg",
"--o2-trace-border": "--color-trace-border",
"--o2-trace-hover": "--color-trace-hover",
"--o2-trace-selected": "--color-trace-selected",
"--o2-trace-surface": "--color-trace-surface",
"--o2-trace-text-primary": "--color-trace-text-primary",
"--o2-trace-text-secondary": "--color-trace-text-secondary",
"--o2-warning": "--color-warning",
"--o2-wildcard-bar-blue": "--color-wildcard-bar-blue",
"--o2-wildcard-bar-green": "--color-wildcard-bar-green",
"--o2-wildcard-bar-orange": "--color-wildcard-bar-orange",
"--o2-wildcard-bar-purple": "--color-wildcard-bar-purple",
"--o2-bg": "--color-surface-base",
"--o2-bg-color": "--color-surface-base",
"--o2-bg-primary": "--color-card-glass-bg",
"--o2-bg-light": "--color-surface-base",
"--o2-bg-dark": "--color-surface-base",
"--o2-bg-card-dark": "--color-surface-base",
"--o2-dark-page-bg": "--color-surface-base",
"--o2-surface": "--color-surface-base",
"--o2-text": "--color-text-heading",
"--o2-text-color": "--color-text-heading",
"--o2-text-primary-dark": "--color-text-heading",
"--o2-primary": "--color-accent",
"--o2-primary-dark": "--color-accent",
"--o2-color-primary": "--color-accent",
"--o2-color-primary-light": "--color-primary-100",
"--o2-brand": "--color-accent",
"--o2-destructive": "--color-error-500",
"--o2-status-error": "--color-status-error-text",
"--o2-status-error-border": "--color-error-300",
"--o2-hover-bg": "--color-interactive-hover-bg",
"--o2-hover-color": "--color-interactive-hover-bg",
"--o2-selected-color": "--color-primary-100",
"--o2-shadow-lg": "--shadow-lg",
"--o2-input-bg": "--color-input-bg",
"--o2-font-mono": "--font-mono"
}
}
+69
View File
@@ -0,0 +1,69 @@
#!/bin/bash
# Raw-token audit over web/src — categorized counts.
cd /Users/ktx/Documents/KTX-Projects/openobserve/web || exit 1
# Common exclusions: tests, specs, mocks, vendored assets, token definition files
EXC=(--glob '!**/*.spec.ts' --glob '!**/*.spec.js' --glob '!src/test/**' --glob '!**/__mocks__/**'
--glob '!src/assets/dashboard/echarts.min.js' --glob '!**/*.json' --glob '!**/*.md')
# Token definition files are the legitimate home of raw values
TOKENDEF=(--glob '!src/lib/styles/tokens/**')
SRC=(-g '*.vue' -g '*.ts' -g '*.js' -g '*.css' -g '*.scss' -g '*.sass')
section() { echo ""; echo "=== $1 ==="; }
section "1. HEX COLORS (app code, excl token defs)"
rg -o -i '#[0-9a-f]{3}(?:[0-9a-f]{3})?(?:[0-9a-f]{2})?\b' src "${SRC[@]}" "${EXC[@]}" "${TOKENDEF[@]}" 2>/dev/null | awk -F: '{print $1}' | sort | uniq -c | sort -rn > /tmp/audit_hex.txt
echo "total: $(awk '{s+=$1} END {print s}' /tmp/audit_hex.txt) files: $(wc -l < /tmp/audit_hex.txt)"
head -25 /tmp/audit_hex.txt
section "2. RGB/RGBA/HSL (app code, excl token defs)"
rg -o -i '\b(rgba?|hsla?)\(' src "${SRC[@]}" "${EXC[@]}" "${TOKENDEF[@]}" 2>/dev/null | awk -F: '{print $1}' | sort | uniq -c | sort -rn > /tmp/audit_rgb.txt
echo "total: $(awk '{s+=$1} END {print s}' /tmp/audit_rgb.txt) files: $(wc -l < /tmp/audit_rgb.txt)"
head -20 /tmp/audit_rgb.txt
section "3. LEGACY --o2-* USAGE (banned)"
rg -o -- '--o2-[a-z0-9-]+' src "${SRC[@]}" "${EXC[@]}" 2>/dev/null | awk -F: '{print $1}' | sort | uniq -c | sort -rn > /tmp/audit_o2.txt
echo "total: $(awk '{s+=$1} END {print s}' /tmp/audit_o2.txt) files: $(wc -l < /tmp/audit_o2.txt)"
head -20 /tmp/audit_o2.txt
section "4. PX VALUES excluding 1px (styles: vue/css/scss only)"
rg -o -i '\b([2-9]|[1-9][0-9]+)(\.[0-9]+)?px\b' src -g '*.vue' -g '*.css' -g '*.scss' -g '*.sass' "${EXC[@]}" "${TOKENDEF[@]}" 2>/dev/null | awk -F: '{print $1}' | sort | uniq -c | sort -rn > /tmp/audit_px.txt
echo "total: $(awk '{s+=$1} END {print s}' /tmp/audit_px.txt) files: $(wc -l < /tmp/audit_px.txt)"
head -25 /tmp/audit_px.txt
section "5. INLINE style= ATTRIBUTES in templates"
rg -c ' style="' src -g '*.vue' "${EXC[@]}" 2>/dev/null | sort -t: -k2 -rn > /tmp/audit_inline.txt
echo "total: $(awk -F: '{s+=$2} END {print s}' /tmp/audit_inline.txt) files: $(wc -l < /tmp/audit_inline.txt)"
head -20 /tmp/audit_inline.txt
section "6. TAILWIND ARBITRARY VALUES [..px] / [#hex]"
rg -o -i '\-\[(#[0-9a-f]{3,8}|[0-9.]+(px|rem|em|vh|vw|%))\]' src -g '*.vue' "${EXC[@]}" 2>/dev/null | awk -F: '{print $1}' | sort | uniq -c | sort -rn > /tmp/audit_arb.txt
echo "total: $(awk '{s+=$1} END {print s}' /tmp/audit_arb.txt) files: $(wc -l < /tmp/audit_arb.txt)"
head -20 /tmp/audit_arb.txt
section "7. TYPO UTILITIES (text-weight)"
rg -o '\b(text-weight-[a-z]+)\b' src -g '*.vue' "${EXC[@]}" 2>/dev/null | awk -F: '{print $1}' | sort | uniq -c | sort -rn > /tmp/audit_typo.txt
echo "total: $(awk '{s+=$1} END {print s}' /tmp/audit_typo.txt) files: $(wc -l < /tmp/audit_typo.txt)"
head -20 /tmp/audit_typo.txt
section "8. PALETTE color= PROPS (color=\"red\" etc.)"
rg -o '\b(color|text-color)="(red|blue|green|orange|purple|teal|pink|amber|grey|gray|cyan|indigo|lime|brown|black|white|primary|secondary|positive|negative|warning|info|accent)(-[0-9]+)?"' src -g '*.vue' "${EXC[@]}" 2>/dev/null | awk -F: '{print $1}' | sort | uniq -c | sort -rn > /tmp/audit_qcolor.txt
echo "total: $(awk '{s+=$1} END {print s}' /tmp/audit_qcolor.txt) files: $(wc -l < /tmp/audit_qcolor.txt)"
head -20 /tmp/audit_qcolor.txt
section "9. HARDCODED font-family"
rg -o -i 'font-family:\s*[^;"}]+' src "${SRC[@]}" "${EXC[@]}" "${TOKENDEF[@]}" 2>/dev/null | awk -F: '{print $1}' | sort | uniq -c | sort -rn > /tmp/audit_font.txt
echo "total: $(awk '{s+=$1} END {print s}' /tmp/audit_font.txt) files: $(wc -l < /tmp/audit_font.txt)"
head -15 /tmp/audit_font.txt
section "10. CSS NAMED COLORS in style contexts (white/black as values)"
rg -o -i '(color|background|background-color|border-color|fill|stroke):\s*(white|black|red|green|blue|orange|yellow|gray|grey|transparent)\b' src -g '*.vue' -g '*.css' -g '*.scss' "${EXC[@]}" "${TOKENDEF[@]}" 2>/dev/null | grep -v -i transparent | awk -F: '{print $1}' | sort | uniq -c | sort -rn > /tmp/audit_named.txt
echo "total: $(awk '{s+=$1} END {print s}' /tmp/audit_named.txt) files: $(wc -l < /tmp/audit_named.txt)"
head -15 /tmp/audit_named.txt
section "11. SCOPED <style> BLOCKS remaining"
rg -c '<style' src -g '*.vue' "${EXC[@]}" 2>/dev/null | wc -l | xargs echo "vue files with <style> blocks:"
section "SUMMARY BY DIRECTORY (hex+rgb+px+o2 combined)"
cat /tmp/audit_hex.txt /tmp/audit_rgb.txt /tmp/audit_px.txt /tmp/audit_o2.txt | awk '{n=$1; $1=""; split($2,p,"/"); print n, p[1]"/"p[2]}' | awk '{a[$2]+=$1} END {for (k in a) print a[k], k}' | sort -rn | head -20
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Copyright 2026 OpenObserve Inc.
# Regenerates the quantitative numbers in web/SPACING_AUDIT.md.
# Usage: bash web/scripts/spacing-audit.sh (requires ripgrep + python3)
# Writes per-category lists to /tmp/spacing_audit_*.txt
set -euo pipefail
cd "$(dirname "$0")/../src"
# ripgrep: use the real binary, or fall back to the one bundled with Claude Code
if ! command -v rg >/dev/null 2>&1; then
_cc="${CLAUDE_CODE_EXECPATH:-$HOME/.local/bin/claude}"
if [[ -x "$_cc" ]]; then
rg() { ( exec -a rg "$_cc" "$@" ); }
else
echo "ripgrep (rg) is required: brew install ripgrep" >&2; exit 1
fi
fi
OUT=/tmp
EXCL=(-g '!*.spec.*' -g '!test/**' -g '!__mocks__/**' -g '!assets/dashboard/echarts.min.js')
PROPS='(?:p|px|py|pt|pr|pb|pl|m|mx|my|mt|mr|mb|ml|gap|gap-x|gap-y|space-x|space-y)'
echo "== 1. Tailwind spacing utility histogram =="
rg -o --no-filename -g '*.vue' "${EXCL[@]}" "\\b${PROPS}-(?:\\d+(?:\\.\\d+)?|\\[[^\\]]+\\])" \
| sort | uniq -c | sort -rn > "$OUT/spacing_audit_utilities.txt"
awk '{s+=$1} END {print "total:", s, "distinct:", NR}' "$OUT/spacing_audit_utilities.txt"
echo "== 2. Scale-step histogram (props aggregated) =="
awk '{n=$1; cls=$2; sub(/^[a-z-]+-/,"",cls); steps[cls]+=n} END {for (s in steps) print steps[s], s}' \
"$OUT/spacing_audit_utilities.txt" | grep -v ' \[' | sort -rn | tee "$OUT/spacing_audit_steps.txt"
echo "== 3. Arbitrary values: convertible vs off-grid =="
grep ' \S*\[' "$OUT/spacing_audit_utilities.txt" > "$OUT/spacing_audit_arbitrary.txt" || true
python3 - "$OUT/spacing_audit_arbitrary.txt" <<'EOF'
import re, sys
conv=off=pxv=other=0; offs=[]
for line in open(sys.argv[1]):
n, cls = int(line.split()[0]), line.split()[1]
m = re.search(r'\[([0-9.]+)rem\]', cls)
if m:
suffix = float(m.group(1)) / 0.25
if abs(suffix*4 - round(suffix*4)) < 1e-9: conv += n
else: off += n; offs.append((n, cls))
elif re.search(r'\[[0-9.]+px\]', cls): pxv += n
else: other += n
print("convertible-to-class:", conv, "| off-grid:", off, "| px:", pxv, "| other:", other)
for n, c in sorted(offs, reverse=True): print(f" {n:4d} {c}")
EOF
echo "== 4. CSS padding/margin declarations =="
rg -o --no-filename -g '*.vue' -g '*.css' -g '*.scss' "${EXCL[@]}" -g '!lib/styles/tokens/**' \
'(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*[^;}"]+' \
| sed 's/\s\+/ /g' | sort | uniq -c | sort -rn > "$OUT/spacing_audit_css.txt"
awk '{s+=$1} END {print "total:", s, "distinct:", NR}' "$OUT/spacing_audit_css.txt"
echo "== 5. Spacing token adoption =="
printf 'var(--spacing-*) outside token files: '
rg --no-filename -g '!lib/styles/tokens/**' -g '!*.spec.*' 'var\(--spacing-' | wc -l | tr -d ' '
echo "== 6. Top files by arbitrary spacing =="
rg -c -g '*.vue' "${EXCL[@]}" "\\b${PROPS}-\\[[^\\]]+\\]" | sort -t: -k2 -rn | head -15
+1 -3
View File
@@ -15,9 +15,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<router-view
:class="store.state.theme === 'dark' ? 'dark-theme' : 'light-theme'"
></router-view>
<router-view></router-view>
<OToastProvider />
<ConfirmDialogProvider />
</template>
-74
View File
@@ -1,74 +0,0 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
position: relative;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition: color 0.5s, background-color 0.5s;
line-height: 1.6;
font-family: 'Nunito Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+29 -29
View File
@@ -1,30 +1,30 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<rect x="26.483" y="66.207" style="fill:#18BDF6;" width="459.034" height="247.172"/>
<path style="fill:#464655;" d="M494.345,39.724H17.655C7.905,39.724,0,47.628,0,57.379v317.793c0,9.75,7.905,17.655,17.655,17.655
h476.69c9.75,0,17.655-7.905,17.655-17.655V57.379C512,47.628,504.095,39.724,494.345,39.724z M485.517,313.379H26.483V66.207
h459.034V313.379z"/>
<path style="fill:#C7CFE2;" d="M0,339.862v35.31c0,9.751,7.904,17.655,17.655,17.655h476.69c9.751,0,17.655-7.904,17.655-17.655
v-35.31H0z"/>
<polygon style="fill:#AFB9D2;" points="326.621,463.448 185.379,463.448 194.207,392.828 317.793,392.828 "/>
<g>
<polygon style="fill:#959CB3;" points="320,410.483 317.793,392.828 194.207,392.828 192,410.483 "/>
<circle style="fill:#959CB3;" cx="256" cy="366.345" r="13.241"/>
</g>
<path style="fill:#C7CFE2;" d="M326.621,472.276H185.379c-4.875,0-8.828-3.953-8.828-8.828l0,0c0-4.875,3.953-8.828,8.828-8.828
h141.241c4.875,0,8.828,3.953,8.828,8.828l0,0C335.448,468.323,331.496,472.276,326.621,472.276z"/>
<path style="fill:#FFFFFF;" d="M256.589,107.052c-60.557,0-113.985,27.772-146.433,70.201c-5.634,7.368-5.634,17.711,0,25.079
c32.448,42.43,85.876,70.201,146.433,70.201s113.985-27.772,146.433-70.201c5.634-7.368,5.634-17.711,0-25.079
C370.574,134.824,317.146,107.052,256.589,107.052z"/>
<circle style="fill:#18BDF6;" cx="256.585" cy="189.793" r="62.058"/>
<path style="fill:#00A0EB;" d="M225.562,189.793c0-28.906,19.791-53.122,46.541-60.022c-4.968-1.281-10.147-2.034-15.513-2.034
c-34.272,0-62.056,27.783-62.056,62.056s27.783,62.056,62.056,62.056c5.367,0,10.547-0.753,15.513-2.034
C245.353,242.915,225.562,218.699,225.562,189.793z"/>
<circle style="fill:#535463;" cx="256.585" cy="189.793" r="31.029"/>
<g>
<circle style="fill:#FFFFFF;" cx="272.099" cy="174.279" r="15.514"/>
<circle style="fill:#FFFFFF;" cx="235.906" cy="210.483" r="10.343"/>
</g>
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<rect x="26.483" y="66.207" style="fill:#18BDF6;" width="459.034" height="247.172"/>
<path style="fill:#464655;" d="M494.345,39.724H17.655C7.905,39.724,0,47.628,0,57.379v317.793c0,9.75,7.905,17.655,17.655,17.655
h476.69c9.75,0,17.655-7.905,17.655-17.655V57.379C512,47.628,504.095,39.724,494.345,39.724z M485.517,313.379H26.483V66.207
h459.034V313.379z"/>
<path style="fill:#C7CFE2;" d="M0,339.862v35.31c0,9.751,7.904,17.655,17.655,17.655h476.69c9.751,0,17.655-7.904,17.655-17.655
v-35.31H0z"/>
<polygon style="fill:#AFB9D2;" points="326.621,463.448 185.379,463.448 194.207,392.828 317.793,392.828 "/>
<g>
<polygon style="fill:#959CB3;" points="320,410.483 317.793,392.828 194.207,392.828 192,410.483 "/>
<circle style="fill:#959CB3;" cx="256" cy="366.345" r="13.241"/>
</g>
<path style="fill:#C7CFE2;" d="M326.621,472.276H185.379c-4.875,0-8.828-3.953-8.828-8.828l0,0c0-4.875,3.953-8.828,8.828-8.828
h141.241c4.875,0,8.828,3.953,8.828,8.828l0,0C335.448,468.323,331.496,472.276,326.621,472.276z"/>
<path style="fill:#FFFFFF;" d="M256.589,107.052c-60.557,0-113.985,27.772-146.433,70.201c-5.634,7.368-5.634,17.711,0,25.079
c32.448,42.43,85.876,70.201,146.433,70.201s113.985-27.772,146.433-70.201c5.634-7.368,5.634-17.711,0-25.079
C370.574,134.824,317.146,107.052,256.589,107.052z"/>
<circle style="fill:#18BDF6;" cx="256.585" cy="189.793" r="62.058"/>
<path style="fill:#00A0EB;" d="M225.562,189.793c0-28.906,19.791-53.122,46.541-60.022c-4.968-1.281-10.147-2.034-15.513-2.034
c-34.272,0-62.056,27.783-62.056,62.056s27.783,62.056,62.056,62.056c5.367,0,10.547-0.753,15.513-2.034
C245.353,242.915,225.562,218.699,225.562,189.793z"/>
<circle style="fill:#535463;" cx="256.585" cy="189.793" r="31.029"/>
<g>
<circle style="fill:#FFFFFF;" cx="272.099" cy="174.279" r="15.514"/>
<circle style="fill:#FFFFFF;" cx="235.906" cy="210.483" r="10.343"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

+70 -70
View File
@@ -1,72 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg version="1.1" id="Icons" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 32 32" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#3A559F;}
.st2{fill:#F4F4F4;}
.st3{fill:#FF0084;}
.st4{fill:#0063DB;}
.st5{fill:#00ACED;}
.st6{fill:#FFEC06;}
.st7{fill:#FF0000;}
.st8{fill:#25D366;}
.st9{fill:#0088FF;}
.st10{fill:#314358;}
.st11{fill:#EE6996;}
.st12{fill:#01AEF3;}
.st13{fill:#FFFEFF;}
.st14{fill:#F06A35;}
.st15{fill:#00ADEF;}
.st16{fill:#1769FF;}
.st17{fill:#1AB7EA;}
.st18{fill:#6001D1;}
.st19{fill:#E41214;}
.st20{fill:#05CE78;}
.st21{fill:#7B519C;}
.st22{fill:#FF4500;}
.st23{fill:#00F076;}
.st24{fill:#FFC900;}
.st25{fill:#00D6FF;}
.st26{fill:#FF3A44;}
.st27{fill:#FF6A36;}
.st28{fill:#0061FE;}
.st29{fill:#F7981C;}
.st30{fill:#EE1B22;}
.st31{fill:#EF3561;}
.st32{fill:none;stroke:#FFFFFF;stroke-width:2;stroke-miterlimit:10;}
.st33{fill:#0097D3;}
.st34{fill:#01308A;}
.st35{fill:#019CDE;}
.st36{fill:#FFD049;}
.st37{fill:#16A05D;}
.st38{fill:#4486F4;}
.st39{fill:none;}
.st40{fill:#34A853;}
.st41{fill:#4285F4;}
.st42{fill:#FBBC05;}
.st43{fill:#EA4335;}
</style>
<g>
<g>
<g>
<path class="st15" d="M30,15H17c-0.6,0-1-0.4-1-1V3.3c0-0.5,0.4-0.9,0.8-1l13-2.3c0.3,0,0.6,0,0.8,0.2C30.9,0.4,31,0.7,31,1v13
C31,14.6,30.6,15,30,15z"/>
</g>
<g>
<path class="st15" d="M13,15H1c-0.6,0-1-0.4-1-1V6c0-0.5,0.4-0.9,0.8-1l12-2c0.3,0,0.6,0,0.8,0.2C13.9,3.4,14,3.7,14,4v10
C14,14.6,13.6,15,13,15z"/>
</g>
<g>
<path class="st15" d="M30,32c-0.1,0-0.1,0-0.2,0l-13-2.3c-0.5-0.1-0.8-0.5-0.8-1V18c0-0.6,0.4-1,1-1h13c0.6,0,1,0.4,1,1v13
c0,0.3-0.1,0.6-0.4,0.8C30.5,31.9,30.2,32,30,32z"/>
</g>
<g>
<path class="st15" d="M13,29c-0.1,0-0.1,0-0.2,0l-12-2C0.4,26.9,0,26.5,0,26v-8c0-0.6,0.4-1,1-1h12c0.6,0,1,0.4,1,1v10
c0,0.3-0.1,0.6-0.4,0.8C13.5,28.9,13.2,29,13,29z"/>
</g>
</g>
</g>
<svg version="1.1" id="Icons" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 32 32" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#3A559F;}
.st2{fill:#F4F4F4;}
.st3{fill:#FF0084;}
.st4{fill:#0063DB;}
.st5{fill:#00ACED;}
.st6{fill:#FFEC06;}
.st7{fill:#FF0000;}
.st8{fill:#25D366;}
.st9{fill:#0088FF;}
.st10{fill:#314358;}
.st11{fill:#EE6996;}
.st12{fill:#01AEF3;}
.st13{fill:#FFFEFF;}
.st14{fill:#F06A35;}
.st15{fill:#00ADEF;}
.st16{fill:#1769FF;}
.st17{fill:#1AB7EA;}
.st18{fill:#6001D1;}
.st19{fill:#E41214;}
.st20{fill:#05CE78;}
.st21{fill:#7B519C;}
.st22{fill:#FF4500;}
.st23{fill:#00F076;}
.st24{fill:#FFC900;}
.st25{fill:#00D6FF;}
.st26{fill:#FF3A44;}
.st27{fill:#FF6A36;}
.st28{fill:#0061FE;}
.st29{fill:#F7981C;}
.st30{fill:#EE1B22;}
.st31{fill:#EF3561;}
.st32{fill:none;stroke:#FFFFFF;stroke-width:2;stroke-miterlimit:10;}
.st33{fill:#0097D3;}
.st34{fill:#01308A;}
.st35{fill:#019CDE;}
.st36{fill:#FFD049;}
.st37{fill:#16A05D;}
.st38{fill:#4486F4;}
.st39{fill:none;}
.st40{fill:#34A853;}
.st41{fill:#4285F4;}
.st42{fill:#FBBC05;}
.st43{fill:#EA4335;}
</style>
<g>
<g>
<g>
<path class="st15" d="M30,15H17c-0.6,0-1-0.4-1-1V3.3c0-0.5,0.4-0.9,0.8-1l13-2.3c0.3,0,0.6,0,0.8,0.2C30.9,0.4,31,0.7,31,1v13
C31,14.6,30.6,15,30,15z"/>
</g>
<g>
<path class="st15" d="M13,15H1c-0.6,0-1-0.4-1-1V6c0-0.5,0.4-0.9,0.8-1l12-2c0.3,0,0.6,0,0.8,0.2C13.9,3.4,14,3.7,14,4v10
C14,14.6,13.6,15,13,15z"/>
</g>
<g>
<path class="st15" d="M30,32c-0.1,0-0.1,0-0.2,0l-13-2.3c-0.5-0.1-0.8-0.5-0.8-1V18c0-0.6,0.4-1,1-1h13c0.6,0,1,0.4,1,1v13
c0,0.3-0.1,0.6-0.4,0.8C30.5,31.9,30.2,32,30,32z"/>
</g>
<g>
<path class="st15" d="M13,29c-0.1,0-0.1,0-0.2,0l-12-2C0.4,26.9,0,26.5,0,26v-8c0-0.6,0.4-1,1-1h12c0.6,0,1,0.4,1,1v10
c0,0.3-0.1,0.6-0.4,0.8C13.5,28.9,13.2,29,13,29z"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

+22 -22
View File
@@ -1,22 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.0" id="katman_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 841.89 595.28" style="enable-background:new 0 0 841.89 595.28;" xml:space="preserve">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#505AA5;}
</style>
<path class="st0" d="M349.6,124.45c48.94-54.99,129.98-71.12,196.61-39.38c88.52,42.17,120.82,149.6,72.62,232.48L510.41,503.76
c-6.06,10.41-16.03,18-27.72,21.11c-11.69,3.11-24.15,1.49-34.64-4.51l131.26-225.49c34.97-60.15,11.58-138.11-52.6-168.8
c-48.16-23.03-107.02-11.53-142.6,28.08c-19.62,21.74-30.64,49.82-31.01,79.01c-0.37,29.2,9.94,57.53,29.01,79.76
c3.43,3.99,7.12,7.75,11.04,11.25l-76.63,131.88c-3,5.16-6.99,9.67-11.74,13.3c-4.76,3.62-10.18,6.28-15.97,7.82
c-5.79,1.54-11.83,1.93-17.77,1.16c-5.94-0.78-11.67-2.71-16.87-5.68l83.19-143.17c-11.95-17.11-20.53-36.31-25.29-56.58
L261.1,360.8c-6.06,10.41-16.03,18-27.72,21.11c-11.69,3.11-24.15,1.49-34.64-4.51l131.83-226.76
C336.06,141.32,342.43,132.55,349.6,124.45z M501.76,196.63c31.75,18.21,42.71,58.7,24.34,90.22L399.69,503.74
c-6.06,10.41-16.03,18-27.72,21.11c-11.69,3.11-24.15,1.49-34.64-4.51l117.38-201.93c-9.42-1.97-18.29-5.94-26.01-11.65
c-7.72-5.71-14.1-13.01-18.7-21.4c-4.6-8.4-7.31-17.68-7.95-27.22c-0.64-9.54,0.82-19.1,4.27-28.02c3.45-8.92,8.8-17,15.7-23.67
c6.9-6.67,15.17-11.77,24.24-14.95c9.08-3.18,18.74-4.37,28.32-3.49S493.43,191.83,501.76,196.63z M455.78,237.39
c-2.17,1.66-4,3.72-5.36,6.08h-0.01c-2.06,3.55-3.02,7.62-2.75,11.71c0.27,4.09,1.76,8,4.27,11.25c2.51,3.25,5.94,5.69,9.84,7
c3.91,1.32,8.12,1.45,12.1,0.39c3.99-1.06,7.56-3.27,10.28-6.35c2.72-3.08,4.46-6.89,5-10.95c0.54-4.06-0.15-8.19-1.97-11.87
c-1.82-3.67-4.71-6.73-8.28-8.78c-2.37-1.36-4.99-2.24-7.71-2.6c-2.72-0.35-5.48-0.18-8.12,0.53
C460.43,234.52,457.95,235.73,455.78,237.39z"/>
</svg>
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.0" id="katman_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 841.89 595.28" style="enable-background:new 0 0 841.89 595.28;" xml:space="preserve">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#505AA5;}
</style>
<path class="st0" d="M349.6,124.45c48.94-54.99,129.98-71.12,196.61-39.38c88.52,42.17,120.82,149.6,72.62,232.48L510.41,503.76
c-6.06,10.41-16.03,18-27.72,21.11c-11.69,3.11-24.15,1.49-34.64-4.51l131.26-225.49c34.97-60.15,11.58-138.11-52.6-168.8
c-48.16-23.03-107.02-11.53-142.6,28.08c-19.62,21.74-30.64,49.82-31.01,79.01c-0.37,29.2,9.94,57.53,29.01,79.76
c3.43,3.99,7.12,7.75,11.04,11.25l-76.63,131.88c-3,5.16-6.99,9.67-11.74,13.3c-4.76,3.62-10.18,6.28-15.97,7.82
c-5.79,1.54-11.83,1.93-17.77,1.16c-5.94-0.78-11.67-2.71-16.87-5.68l83.19-143.17c-11.95-17.11-20.53-36.31-25.29-56.58
L261.1,360.8c-6.06,10.41-16.03,18-27.72,21.11c-11.69,3.11-24.15,1.49-34.64-4.51l131.83-226.76
C336.06,141.32,342.43,132.55,349.6,124.45z M501.76,196.63c31.75,18.21,42.71,58.7,24.34,90.22L399.69,503.74
c-6.06,10.41-16.03,18-27.72,21.11c-11.69,3.11-24.15,1.49-34.64-4.51l117.38-201.93c-9.42-1.97-18.29-5.94-26.01-11.65
c-7.72-5.71-14.1-13.01-18.7-21.4c-4.6-8.4-7.31-17.68-7.95-27.22c-0.64-9.54,0.82-19.1,4.27-28.02c3.45-8.92,8.8-17,15.7-23.67
c6.9-6.67,15.17-11.77,24.24-14.95c9.08-3.18,18.74-4.37,28.32-3.49S493.43,191.83,501.76,196.63z M455.78,237.39
c-2.17,1.66-4,3.72-5.36,6.08h-0.01c-2.06,3.55-3.02,7.62-2.75,11.71c0.27,4.09,1.76,8,4.27,11.25c2.51,3.25,5.94,5.69,9.84,7
c3.91,1.32,8.12,1.45,12.1,0.39c3.99-1.06,7.56-3.27,10.28-6.35c2.72-3.08,4.46-6.89,5-10.95c0.54-4.06-0.15-8.19-1.97-11.87
c-1.82-3.67-4.71-6.73-8.28-8.78c-2.37-1.36-4.99-2.24-7.71-2.6c-2.72-0.35-5.48-0.18-8.12,0.53
C460.43,234.52,457.95,235.73,455.78,237.39z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

+9 -9
View File
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.0497 8.44062C22.6378 3.32607 19.2566 0 19.2566 0C19.7598 5.28738 13.813 6.53583 12.2189 10.1692C11.1312 12.6485 12.9638 14.8193 16.0475 17.5554C15.7749 16.9494 15.3544 16.3606 14.9288 15.7645C13.4769 13.7313 11.9645 11.6132 16.0497 8.44062Z" fill="#E76F00"/>
<path d="M17.1015 18.677C17.1015 18.677 19.0835 17.0779 17.5139 15.3008C12.1931 9.27186 23.3333 6.53583 23.3333 6.53583C16.5317 9.8125 17.5471 11.7574 19.2567 14.1202C21.0871 16.6538 17.1015 18.677 17.1015 18.677Z" fill="#E76F00"/>
<path d="M22.937 23.4456C29.0423 20.3258 26.2195 17.3278 24.2492 17.7317C23.7662 17.8305 23.5509 17.9162 23.5509 17.9162C23.5509 17.9162 23.7302 17.64 24.0726 17.5204C27.9705 16.1729 30.9682 21.4949 22.8143 23.6028C22.8143 23.6029 22.9088 23.5198 22.937 23.4456Z" fill="#5382A1"/>
<path d="M10.233 19.4969C6.41312 18.9953 12.3275 17.6139 12.3275 17.6139C12.3275 17.6139 10.0307 17.4616 7.20592 18.8043C3.86577 20.3932 15.4681 21.1158 21.474 19.5625C22.0984 19.1432 22.9614 18.7798 22.9614 18.7798C22.9614 18.7798 20.5037 19.2114 18.0561 19.4145C15.0612 19.6612 11.8459 19.7093 10.233 19.4969Z" fill="#5382A1"/>
<path d="M11.6864 22.4758C9.55624 22.2592 10.951 21.2439 10.951 21.2439C5.43898 23.0429 14.0178 25.083 21.7199 22.8682C20.9012 22.5844 20.3806 22.0653 20.3806 22.0653C16.6163 22.7781 14.441 22.7553 11.6864 22.4758Z" fill="#5382A1"/>
<path d="M12.6145 25.6991C10.486 25.4585 11.7295 24.7474 11.7295 24.7474C6.72594 26.1222 14.7729 28.9625 21.1433 26.2777C20.0999 25.8787 19.3528 25.4181 19.3528 25.4181C16.5111 25.9469 15.1931 25.9884 12.6145 25.6991Z" fill="#5382A1"/>
<path d="M25.9387 27.3388C25.9387 27.3388 26.8589 28.0844 24.9252 28.6612C21.2481 29.7566 9.62093 30.0874 6.39094 28.7049C5.22984 28.2082 7.40723 27.5189 8.09215 27.3742C8.80646 27.2219 9.21466 27.2503 9.21466 27.2503C7.9234 26.3558 0.868489 29.0067 5.63111 29.7659C18.6195 31.8372 29.3077 28.8331 25.9387 27.3388Z" fill="#5382A1"/>
<path d="M28 28.9679C27.7869 31.6947 18.7877 32.2683 12.9274 31.8994C9.10432 31.6583 8.33812 31.0558 8.32691 31.047C11.9859 31.6402 18.1549 31.7482 23.1568 30.8225C27.5903 30.0016 28 28.9679 28 28.9679Z" fill="#5382A1"/>
<svg width="800px" height="800px" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.0497 8.44062C22.6378 3.32607 19.2566 0 19.2566 0C19.7598 5.28738 13.813 6.53583 12.2189 10.1692C11.1312 12.6485 12.9638 14.8193 16.0475 17.5554C15.7749 16.9494 15.3544 16.3606 14.9288 15.7645C13.4769 13.7313 11.9645 11.6132 16.0497 8.44062Z" fill="#E76F00"/>
<path d="M17.1015 18.677C17.1015 18.677 19.0835 17.0779 17.5139 15.3008C12.1931 9.27186 23.3333 6.53583 23.3333 6.53583C16.5317 9.8125 17.5471 11.7574 19.2567 14.1202C21.0871 16.6538 17.1015 18.677 17.1015 18.677Z" fill="#E76F00"/>
<path d="M22.937 23.4456C29.0423 20.3258 26.2195 17.3278 24.2492 17.7317C23.7662 17.8305 23.5509 17.9162 23.5509 17.9162C23.5509 17.9162 23.7302 17.64 24.0726 17.5204C27.9705 16.1729 30.9682 21.4949 22.8143 23.6028C22.8143 23.6029 22.9088 23.5198 22.937 23.4456Z" fill="#5382A1"/>
<path d="M10.233 19.4969C6.41312 18.9953 12.3275 17.6139 12.3275 17.6139C12.3275 17.6139 10.0307 17.4616 7.20592 18.8043C3.86577 20.3932 15.4681 21.1158 21.474 19.5625C22.0984 19.1432 22.9614 18.7798 22.9614 18.7798C22.9614 18.7798 20.5037 19.2114 18.0561 19.4145C15.0612 19.6612 11.8459 19.7093 10.233 19.4969Z" fill="#5382A1"/>
<path d="M11.6864 22.4758C9.55624 22.2592 10.951 21.2439 10.951 21.2439C5.43898 23.0429 14.0178 25.083 21.7199 22.8682C20.9012 22.5844 20.3806 22.0653 20.3806 22.0653C16.6163 22.7781 14.441 22.7553 11.6864 22.4758Z" fill="#5382A1"/>
<path d="M12.6145 25.6991C10.486 25.4585 11.7295 24.7474 11.7295 24.7474C6.72594 26.1222 14.7729 28.9625 21.1433 26.2777C20.0999 25.8787 19.3528 25.4181 19.3528 25.4181C16.5111 25.9469 15.1931 25.9884 12.6145 25.6991Z" fill="#5382A1"/>
<path d="M25.9387 27.3388C25.9387 27.3388 26.8589 28.0844 24.9252 28.6612C21.2481 29.7566 9.62093 30.0874 6.39094 28.7049C5.22984 28.2082 7.40723 27.5189 8.09215 27.3742C8.80646 27.2219 9.21466 27.2503 9.21466 27.2503C7.9234 26.3558 0.868489 29.0067 5.63111 29.7659C18.6195 31.8372 29.3077 28.8331 25.9387 27.3388Z" fill="#5382A1"/>
<path d="M28 28.9679C27.7869 31.6947 18.7877 32.2683 12.9274 31.8994C9.10432 31.6583 8.33812 31.0558 8.32691 31.047C11.9859 31.6402 18.1549 31.7482 23.1568 30.8225C27.5903 30.0016 28 28.9679 28 28.9679Z" fill="#5382A1"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -1,25 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.0" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 432 332.4" style="enable-background:new 0 0 432 332.4;" xml:space="preserve">
<style type="text/css">
.st0{fill:#F25022;}
.st1{fill:#7FBA00;}
.st2{fill:#00A4EF;}
.st3{fill:#FFB900;}
</style>
<g>
<g>
<path d="M209.2,103.1V229h16.2V103.1H209.2L209.2,103.1z M254,103.1V229h16.2V103.1H254L254,103.1z M292,222.9
c6.7,4.4,19.9,8.1,32.3,8.1c29.6,0,44.1-16.8,44.1-36.4c0-18.5-10.8-29-32.3-37c-17.5-6.7-24.9-12.5-24.9-24.2
c0-8.8,6.4-18.9,23.9-18.9c11.4,0,19.9,3.7,23.9,6.1l4.4-13.1c-5.7-3-14.8-6.1-27.9-6.1c-24.6,0-40.7,14.5-40.7,34.3
c0,17.8,12.8,28.3,33.3,35.7c17.2,6.4,23.9,13.1,23.9,24.9c0,12.8-9.8,21.6-26.3,21.6c-11.1,0-21.9-3.7-29.3-8.1L292,222.9z"/>
</g>
<g>
<path class="st0" d="M63.6,101.8h60.9v60.9H63.6V101.8z"/>
<path class="st1" d="M130.7,101.8h60.9v60.9h-60.9V101.8z"/>
<path class="st2" d="M63.6,169.2h60.9V230H63.6V169.2z"/>
<path class="st3" d="M130.7,169.2h60.9V230h-60.9V169.2z"/>
</g>
</g>
</svg>
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.0" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 432 332.4" style="enable-background:new 0 0 432 332.4;" xml:space="preserve">
<style type="text/css">
.st0{fill:#F25022;}
.st1{fill:#7FBA00;}
.st2{fill:#00A4EF;}
.st3{fill:#FFB900;}
</style>
<g>
<g>
<path d="M209.2,103.1V229h16.2V103.1H209.2L209.2,103.1z M254,103.1V229h16.2V103.1H254L254,103.1z M292,222.9
c6.7,4.4,19.9,8.1,32.3,8.1c29.6,0,44.1-16.8,44.1-36.4c0-18.5-10.8-29-32.3-37c-17.5-6.7-24.9-12.5-24.9-24.2
c0-8.8,6.4-18.9,23.9-18.9c11.4,0,19.9,3.7,23.9,6.1l4.4-13.1c-5.7-3-14.8-6.1-27.9-6.1c-24.6,0-40.7,14.5-40.7,34.3
c0,17.8,12.8,28.3,33.3,35.7c17.2,6.4,23.9,13.1,23.9,24.9c0,12.8-9.8,21.6-26.3,21.6c-11.1,0-21.9-3.7-29.3-8.1L292,222.9z"/>
</g>
<g>
<path class="st0" d="M63.6,101.8h60.9v60.9H63.6V101.8z"/>
<path class="st1" d="M130.7,101.8h60.9v60.9h-60.9V101.8z"/>
<path class="st2" d="M63.6,169.2h60.9V230H63.6V169.2z"/>
<path class="st3" d="M130.7,169.2h60.9V230h-60.9V169.2z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

+13 -13
View File
@@ -1,15 +1,15 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.0164 2C10.8193 2 9.03825 3.72453 9.03825 5.85185V8.51852H15.9235V9.25926H5.97814C3.78107 9.25926 2 10.9838 2 13.1111L2 18.8889C2 21.0162 3.78107 22.7407 5.97814 22.7407H8.27322V19.4815C8.27322 17.3542 10.0543 15.6296 12.2514 15.6296H19.5956C21.4547 15.6296 22.9617 14.1704 22.9617 12.3704V5.85185C22.9617 3.72453 21.1807 2 18.9836 2H13.0164ZM12.0984 6.74074C12.8589 6.74074 13.4754 6.14378 13.4754 5.40741C13.4754 4.67103 12.8589 4.07407 12.0984 4.07407C11.3378 4.07407 10.7213 4.67103 10.7213 5.40741C10.7213 6.14378 11.3378 6.74074 12.0984 6.74074Z" fill="url(#paint0_linear_87_8204)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.9834 30C21.1805 30 22.9616 28.2755 22.9616 26.1482V23.4815L16.0763 23.4815L16.0763 22.7408L26.0217 22.7408C28.2188 22.7408 29.9998 21.0162 29.9998 18.8889V13.1111C29.9998 10.9838 28.2188 9.25928 26.0217 9.25928L23.7266 9.25928V12.5185C23.7266 14.6459 21.9455 16.3704 19.7485 16.3704L12.4042 16.3704C10.5451 16.3704 9.03809 17.8296 9.03809 19.6296L9.03809 26.1482C9.03809 28.2755 10.8192 30 13.0162 30H18.9834ZM19.9015 25.2593C19.1409 25.2593 18.5244 25.8562 18.5244 26.5926C18.5244 27.329 19.1409 27.9259 19.9015 27.9259C20.662 27.9259 21.2785 27.329 21.2785 26.5926C21.2785 25.8562 20.662 25.2593 19.9015 25.2593Z" fill="url(#paint1_linear_87_8204)"/>
<defs>
<linearGradient id="paint0_linear_87_8204" x1="12.4809" y1="2" x2="12.4809" y2="22.7407" gradientUnits="userSpaceOnUse">
<stop stop-color="#327EBD"/>
<stop offset="1" stop-color="#1565A7"/>
</linearGradient>
<linearGradient id="paint1_linear_87_8204" x1="19.519" y1="9.25928" x2="19.519" y2="30" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFDA4B"/>
<stop offset="1" stop-color="#F9C600"/>
</linearGradient>
</defs>
<svg width="800px" height="800px" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.0164 2C10.8193 2 9.03825 3.72453 9.03825 5.85185V8.51852H15.9235V9.25926H5.97814C3.78107 9.25926 2 10.9838 2 13.1111L2 18.8889C2 21.0162 3.78107 22.7407 5.97814 22.7407H8.27322V19.4815C8.27322 17.3542 10.0543 15.6296 12.2514 15.6296H19.5956C21.4547 15.6296 22.9617 14.1704 22.9617 12.3704V5.85185C22.9617 3.72453 21.1807 2 18.9836 2H13.0164ZM12.0984 6.74074C12.8589 6.74074 13.4754 6.14378 13.4754 5.40741C13.4754 4.67103 12.8589 4.07407 12.0984 4.07407C11.3378 4.07407 10.7213 4.67103 10.7213 5.40741C10.7213 6.14378 11.3378 6.74074 12.0984 6.74074Z" fill="url(#paint0_linear_87_8204)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.9834 30C21.1805 30 22.9616 28.2755 22.9616 26.1482V23.4815L16.0763 23.4815L16.0763 22.7408L26.0217 22.7408C28.2188 22.7408 29.9998 21.0162 29.9998 18.8889V13.1111C29.9998 10.9838 28.2188 9.25928 26.0217 9.25928L23.7266 9.25928V12.5185C23.7266 14.6459 21.9455 16.3704 19.7485 16.3704L12.4042 16.3704C10.5451 16.3704 9.03809 17.8296 9.03809 19.6296L9.03809 26.1482C9.03809 28.2755 10.8192 30 13.0162 30H18.9834ZM19.9015 25.2593C19.1409 25.2593 18.5244 25.8562 18.5244 26.5926C18.5244 27.329 19.1409 27.9259 19.9015 27.9259C20.662 27.9259 21.2785 27.329 21.2785 26.5926C21.2785 25.8562 20.662 25.2593 19.9015 25.2593Z" fill="url(#paint1_linear_87_8204)"/>
<defs>
<linearGradient id="paint0_linear_87_8204" x1="12.4809" y1="2" x2="12.4809" y2="22.7407" gradientUnits="userSpaceOnUse">
<stop stop-color="#327EBD"/>
<stop offset="1" stop-color="#1565A7"/>
</linearGradient>
<linearGradient id="paint1_linear_87_8204" x1="19.519" y1="9.25928" x2="19.519" y2="30" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFDA4B"/>
<stop offset="1" stop-color="#F9C600"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

+56 -56
View File
@@ -1,56 +1,56 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512.009 512.009" style="enable-background:new 0 0 512.009 512.009;" xml:space="preserve">
<g>
<g>
<g>
<path d="M256.009,42.671c-0.002,0-0.005,0-0.007,0c-0.001,0-0.001,0-0.002,0c-0.001,0-0.002,0-0.003,0
c-70.671,0.003-134.648,28.637-180.967,74.935c-0.016,0.016-0.034,0.029-0.05,0.045c-0.017,0.017-0.03,0.035-0.047,0.052
C28.688,163.976,0.072,227.867,0.011,298.445C0.011,298.521,0,298.595,0,298.671c0,0.073,0.01,0.143,0.011,0.215
c0.05,60.201,20.962,117.239,58.515,162.704c4.053,4.907,10.084,7.748,16.448,7.748h95.693h170.667h95.688
c6.364,0,12.395-2.841,16.448-7.748c37.607-45.53,58.539-102.65,58.539-162.919C512.009,157.289,397.391,42.671,256.009,42.671z
M197.707,426.671c7.376-12.754,21.165-21.333,36.96-21.333h42.667c15.795,0,29.584,8.579,36.96,21.333H197.707z M426.68,426.671
h-66.702c-9.472-36.807-42.88-64-82.645-64h-42.667c-39.765,0-73.173,27.193-82.645,64H85.316
c-23.281-30.977-37.712-67.661-41.583-106.667h62.934c11.782,0,21.333-9.551,21.333-21.333c0-11.782-9.551-21.333-21.333-21.333
H43.734c4.259-42.905,21.23-82.066,47.091-113.671l14.32,14.32c8.331,8.331,21.839,8.331,30.17,0
c8.331-8.331,8.331-21.839,0-30.17l-14.321-14.321c31.605-25.864,70.765-42.837,113.672-47.098v62.941
c0,11.782,9.551,21.333,21.333,21.333c11.782,0,21.333-9.551,21.333-21.333V86.396c42.906,4.259,82.068,21.232,113.676,47.096
l-14.325,14.325c-8.331,8.331-8.331,21.839,0,30.17c8.331,8.331,21.839,8.331,30.17,0l14.326-14.326
c25.867,31.607,42.842,70.771,47.103,113.677h-62.95c-11.782,0-21.333,9.551-21.333,21.333c0,11.782,9.551,21.333,21.333,21.333
h62.95C464.409,359.001,449.97,395.686,426.68,426.671z"/>
<polygon points="259.659,341.338 319.989,220.655 199.328,281.007 "/>
</g>
</g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512.009 512.009" style="enable-background:new 0 0 512.009 512.009;" xml:space="preserve">
<g>
<g>
<g>
<path d="M256.009,42.671c-0.002,0-0.005,0-0.007,0c-0.001,0-0.001,0-0.002,0c-0.001,0-0.002,0-0.003,0
c-70.671,0.003-134.648,28.637-180.967,74.935c-0.016,0.016-0.034,0.029-0.05,0.045c-0.017,0.017-0.03,0.035-0.047,0.052
C28.688,163.976,0.072,227.867,0.011,298.445C0.011,298.521,0,298.595,0,298.671c0,0.073,0.01,0.143,0.011,0.215
c0.05,60.201,20.962,117.239,58.515,162.704c4.053,4.907,10.084,7.748,16.448,7.748h95.693h170.667h95.688
c6.364,0,12.395-2.841,16.448-7.748c37.607-45.53,58.539-102.65,58.539-162.919C512.009,157.289,397.391,42.671,256.009,42.671z
M197.707,426.671c7.376-12.754,21.165-21.333,36.96-21.333h42.667c15.795,0,29.584,8.579,36.96,21.333H197.707z M426.68,426.671
h-66.702c-9.472-36.807-42.88-64-82.645-64h-42.667c-39.765,0-73.173,27.193-82.645,64H85.316
c-23.281-30.977-37.712-67.661-41.583-106.667h62.934c11.782,0,21.333-9.551,21.333-21.333c0-11.782-9.551-21.333-21.333-21.333
H43.734c4.259-42.905,21.23-82.066,47.091-113.671l14.32,14.32c8.331,8.331,21.839,8.331,30.17,0
c8.331-8.331,8.331-21.839,0-30.17l-14.321-14.321c31.605-25.864,70.765-42.837,113.672-47.098v62.941
c0,11.782,9.551,21.333,21.333,21.333c11.782,0,21.333-9.551,21.333-21.333V86.396c42.906,4.259,82.068,21.232,113.676,47.096
l-14.325,14.325c-8.331,8.331-8.331,21.839,0,30.17c8.331,8.331,21.839,8.331,30.17,0l14.326-14.326
c25.867,31.607,42.842,70.771,47.103,113.677h-62.95c-11.782,0-21.333,9.551-21.333,21.333c0,11.782,9.551,21.333,21.333,21.333
h62.95C464.409,359.001,449.97,395.686,426.68,426.671z"/>
<polygon points="259.659,341.338 319.989,220.655 199.328,281.007 "/>
</g>
</g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

+36 -36
View File
@@ -1,37 +1,37 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 380 380" xml:space="preserve">
<g>
<path d="M215.77,216.23h-23.969v-56.906c0-3.314-2.686-6-6-6c-3.313,0-6,2.686-6,6v62.906c0,3.313,2.687,6,6,6h29.969
c3.313,0,6-2.687,6-6C221.77,218.917,219.083,216.23,215.77,216.23z"/>
<path d="M72.102,185.826c-10.658-3.229-14.999-5.188-14.999-11.676c0-5.867,7.579-7.949,14.07-7.949
c4.704,0,10.122,1.458,13.482,3.629c2.783,1.797,6.497,0.999,8.296-1.784c1.798-2.783,0.999-6.497-1.784-8.295
c-5.299-3.423-12.96-5.55-19.994-5.55c-15.594,0-26.07,8.017-26.07,19.949c0,16.037,13.595,20.154,23.521,23.16
c10.963,3.32,14.144,5.094,14.099,10.498c0,5.536-7.288,7.5-13.529,7.5c-5.879,0-12.265-2.573-15.891-6.404
c-2.277-2.407-6.077-2.511-8.481-0.233c-2.407,2.278-2.512,6.075-0.233,8.482c5.929,6.264,15.357,10.155,24.605,10.155
c15.27,0,25.529-7.836,25.529-19.45C94.848,192.715,81.701,188.733,72.102,185.826z"/>
<path d="M133.042,154.148c-19.883,0-36.059,15.8-36.059,35.221c0,19.467,16.176,35.305,36.059,35.305
c3.588,0,7.052-0.522,10.325-1.482l3.266,3.851c1.187,1.399,2.877,2.119,4.579,2.119c1.371,0,2.75-0.468,3.878-1.424
c2.527-2.144,2.839-5.93,0.695-8.457l-1.308-1.542c8.865-6.438,14.623-16.757,14.623-28.369
C169.101,169.948,152.925,154.148,133.042,154.148z M146.686,208.55l-4.873-5.746c-2.142-2.526-5.928-2.837-8.457-0.695
c-2.527,2.144-2.839,5.93-0.695,8.457l1.754,2.068c-0.455,0.025-0.911,0.04-1.372,0.04c-13.266,0-24.059-10.455-24.059-23.305
c0-12.804,10.793-23.221,24.059-23.221c13.266,0,24.059,10.417,24.059,23.221C157.101,197.314,152.971,204.341,146.686,208.55z"/>
<path d="M368.317,46.487H11.684C5.24,46.487,0,51.73,0,58.173v263.656c0,6.442,5.24,11.684,11.684,11.684h356.634
c6.442,0,11.683-5.242,11.683-11.684V58.173C380,51.73,374.76,46.487,368.317,46.487z M320.868,79.771
c6.844,0,12.387,5.547,12.387,12.387c0,6.842-5.543,12.387-12.387,12.387c-6.839,0-12.385-5.545-12.385-12.387
C308.483,85.318,314.029,79.771,320.868,79.771z M283.715,79.771c6.839,0,12.386,5.547,12.386,12.387
c0,6.842-5.547,12.387-12.386,12.387c-6.839,0-12.387-5.545-12.387-12.387C271.328,85.318,276.876,79.771,283.715,79.771z
M246.561,79.771c6.839,0,12.381,5.547,12.381,12.387c0,6.842-5.542,12.387-12.381,12.387c-6.844,0-12.387-5.545-12.387-12.387
C234.175,85.318,239.718,79.771,246.561,79.771z M21.27,314.01V141.424H358.73v66.774c-1.537-2.202-3.678-4.02-6.327-5.143
l-20.614-8.726c-1.834-0.778-3.771-1.172-5.754-1.172c-5.956,0-11.304,3.54-13.623,9.02l-1.252,2.959
c-9.228,0.129-18.383,2.255-26.729,6.193l-2.426-2.107c-1.175-1.018-2.498-1.841-3.932-2.447c-1.841-0.779-3.783-1.174-5.772-1.174
c-4.276,0-8.341,1.858-11.154,5.099l-14.671,16.915c-2.584,2.986-3.855,6.794-3.581,10.735c0.285,3.941,2.085,7.535,5.065,10.115
l2.417,2.098c-2.755,8.849-3.562,18.179-2.365,27.344l-2.748,1.654c-6.982,4.2-9.242,13.302-5.039,20.293L249.344,315H22.26
C21.714,315,21.27,314.556,21.27,314.01z M280.949,258.768c5.162-12.192,17.054-20.07,30.295-20.07
c4.411,0,8.722,0.878,12.813,2.61c16.697,7.072,24.529,26.408,17.461,43.105c-5.166,12.191-17.058,20.068-30.294,20.068
c0,0-0.001,0-0.002,0c-4.41,0-8.721-0.878-12.811-2.611c-8.089-3.424-14.36-9.793-17.658-17.935
C277.455,275.794,277.524,266.856,280.949,258.768z"/>
</g>
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 380 380" xml:space="preserve">
<g>
<path d="M215.77,216.23h-23.969v-56.906c0-3.314-2.686-6-6-6c-3.313,0-6,2.686-6,6v62.906c0,3.313,2.687,6,6,6h29.969
c3.313,0,6-2.687,6-6C221.77,218.917,219.083,216.23,215.77,216.23z"/>
<path d="M72.102,185.826c-10.658-3.229-14.999-5.188-14.999-11.676c0-5.867,7.579-7.949,14.07-7.949
c4.704,0,10.122,1.458,13.482,3.629c2.783,1.797,6.497,0.999,8.296-1.784c1.798-2.783,0.999-6.497-1.784-8.295
c-5.299-3.423-12.96-5.55-19.994-5.55c-15.594,0-26.07,8.017-26.07,19.949c0,16.037,13.595,20.154,23.521,23.16
c10.963,3.32,14.144,5.094,14.099,10.498c0,5.536-7.288,7.5-13.529,7.5c-5.879,0-12.265-2.573-15.891-6.404
c-2.277-2.407-6.077-2.511-8.481-0.233c-2.407,2.278-2.512,6.075-0.233,8.482c5.929,6.264,15.357,10.155,24.605,10.155
c15.27,0,25.529-7.836,25.529-19.45C94.848,192.715,81.701,188.733,72.102,185.826z"/>
<path d="M133.042,154.148c-19.883,0-36.059,15.8-36.059,35.221c0,19.467,16.176,35.305,36.059,35.305
c3.588,0,7.052-0.522,10.325-1.482l3.266,3.851c1.187,1.399,2.877,2.119,4.579,2.119c1.371,0,2.75-0.468,3.878-1.424
c2.527-2.144,2.839-5.93,0.695-8.457l-1.308-1.542c8.865-6.438,14.623-16.757,14.623-28.369
C169.101,169.948,152.925,154.148,133.042,154.148z M146.686,208.55l-4.873-5.746c-2.142-2.526-5.928-2.837-8.457-0.695
c-2.527,2.144-2.839,5.93-0.695,8.457l1.754,2.068c-0.455,0.025-0.911,0.04-1.372,0.04c-13.266,0-24.059-10.455-24.059-23.305
c0-12.804,10.793-23.221,24.059-23.221c13.266,0,24.059,10.417,24.059,23.221C157.101,197.314,152.971,204.341,146.686,208.55z"/>
<path d="M368.317,46.487H11.684C5.24,46.487,0,51.73,0,58.173v263.656c0,6.442,5.24,11.684,11.684,11.684h356.634
c6.442,0,11.683-5.242,11.683-11.684V58.173C380,51.73,374.76,46.487,368.317,46.487z M320.868,79.771
c6.844,0,12.387,5.547,12.387,12.387c0,6.842-5.543,12.387-12.387,12.387c-6.839,0-12.385-5.545-12.385-12.387
C308.483,85.318,314.029,79.771,320.868,79.771z M283.715,79.771c6.839,0,12.386,5.547,12.386,12.387
c0,6.842-5.547,12.387-12.386,12.387c-6.839,0-12.387-5.545-12.387-12.387C271.328,85.318,276.876,79.771,283.715,79.771z
M246.561,79.771c6.839,0,12.381,5.547,12.381,12.387c0,6.842-5.542,12.387-12.381,12.387c-6.844,0-12.387-5.545-12.387-12.387
C234.175,85.318,239.718,79.771,246.561,79.771z M21.27,314.01V141.424H358.73v66.774c-1.537-2.202-3.678-4.02-6.327-5.143
l-20.614-8.726c-1.834-0.778-3.771-1.172-5.754-1.172c-5.956,0-11.304,3.54-13.623,9.02l-1.252,2.959
c-9.228,0.129-18.383,2.255-26.729,6.193l-2.426-2.107c-1.175-1.018-2.498-1.841-3.932-2.447c-1.841-0.779-3.783-1.174-5.772-1.174
c-4.276,0-8.341,1.858-11.154,5.099l-14.671,16.915c-2.584,2.986-3.855,6.794-3.581,10.735c0.285,3.941,2.085,7.535,5.065,10.115
l2.417,2.098c-2.755,8.849-3.562,18.179-2.365,27.344l-2.748,1.654c-6.982,4.2-9.242,13.302-5.039,20.293L249.344,315H22.26
C21.714,315,21.27,314.556,21.27,314.01z M280.949,258.768c5.162-12.192,17.054-20.07,30.295-20.07
c4.411,0,8.722,0.878,12.813,2.61c16.697,7.072,24.529,26.408,17.461,43.105c-5.166,12.191-17.058,20.068-30.294,20.068
c0,0-0.001,0-0.002,0c-4.41,0-8.721-0.878-12.811-2.611c-8.089-3.424-14.36-9.793-17.658-17.935
C277.455,275.794,277.524,266.856,280.949,258.768z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

+30 -30
View File
@@ -1,31 +1,31 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 493.583 493.583" xml:space="preserve">
<g>
<path d="M474.332,170.706c-3.889-49.328-26.734-94.184-64.346-126.313C376.486,15.759,333.787,0,289.761,0
c-54.305,0-105.654,23.649-140.869,64.89c-32.146,37.601-47.715,85.476-43.828,134.797c1.543,19.635,6.199,38.496,13.479,56.184
l-39.986,46.82c-9.451,11.051-8.133,27.647,2.906,37.098l4.742,4.049l-55.156,64.555c-18.525,21.684-15.953,54.283,5.73,72.809
c21.674,18.525,54.277,15.973,72.799-5.701l55.154-64.537l4.725,4.043c5.322,4.536,12.213,6.766,19.168,6.222
c6.955-0.549,13.414-3.825,17.947-9.144l40.102-46.914c13.975,3.357,28.357,5.241,42.926,5.241
c54.289,0,105.656-23.65,140.904-64.895C462.634,267.892,478.185,220.026,474.332,170.706z M392.974,273.448
c-25.869,30.253-63.527,47.605-103.375,47.605c-32.262,0-63.57-11.57-88.139-32.586c-27.602-23.551-44.359-56.455-47.201-92.635
c-2.842-36.185,8.578-71.305,32.146-98.872c25.85-30.253,63.508-47.606,103.355-47.606c32.264,0,63.59,11.566,88.176,32.57
c27.584,23.568,44.342,56.472,47.166,92.65C427.962,210.743,416.542,245.858,392.974,273.448z"/>
<path d="M289.699,149.575c-19.664,0-35.654,15.988-35.654,35.617c0,19.637,15.99,35.621,35.654,35.621
c19.635,0,35.602-15.984,35.602-35.621C325.3,165.564,309.333,149.575,289.699,149.575z"/>
<path d="M382.269,202.673l-12.916-7.451c0.422-3.328,1.027-6.586,1.027-10.029c0-3.436-0.605-6.695-1.027-10.022l12.916-7.468
c3.217-1.85,5.592-4.934,6.557-8.533c0.965-3.598,0.467-7.439-1.416-10.668l-17.092-29.595c-3.854-6.718-12.467-9.03-19.215-5.159
l-12.789,7.424c-5.43-4.113-11.166-7.76-17.48-10.41V96.267c0-7.775-6.295-14.054-14.07-14.054h-34.158
c-7.775,0-14.06,6.278-14.06,14.054v14.494c-6.348,2.65-12.066,6.297-17.463,10.41l-12.836-7.424
c-3.229-1.859-7.088-2.373-10.686-1.396c-3.6,0.964-6.65,3.31-8.533,6.556l-17.074,29.595c-3.889,6.717-1.578,15.323,5.141,19.201
l12.916,7.468c-0.416,3.327-1.012,6.587-1.012,10.022c0,3.443,0.596,6.701,1.012,10.029l-12.916,7.451
c-6.719,3.877-9.029,12.483-5.141,19.219l17.074,29.595c1.883,3.229,4.934,5.591,8.533,6.557c3.598,0.958,7.457,0.46,10.686-1.398
l12.836-7.423c5.396,4.112,11.115,7.758,17.463,10.426v14.479c0,7.773,6.285,14.054,14.06,14.054h34.158
c7.775,0,14.07-6.28,14.07-14.054v-14.479c6.332-2.668,12.051-6.313,17.48-10.426l12.789,7.423c3.248,1.858,7.1,2.357,10.699,1.398
c3.6-0.966,6.684-3.328,8.516-6.557l17.092-29.595C391.304,215.173,388.988,206.561,382.269,202.673z M289.699,237.265
c-28.728,0-52.105-23.356-52.105-52.072c0-28.709,23.377-52.07,52.105-52.07c28.709,0,52.053,23.361,52.053,52.07
C341.751,213.908,318.408,237.265,289.699,237.265z"/>
</g>
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 493.583 493.583" xml:space="preserve">
<g>
<path d="M474.332,170.706c-3.889-49.328-26.734-94.184-64.346-126.313C376.486,15.759,333.787,0,289.761,0
c-54.305,0-105.654,23.649-140.869,64.89c-32.146,37.601-47.715,85.476-43.828,134.797c1.543,19.635,6.199,38.496,13.479,56.184
l-39.986,46.82c-9.451,11.051-8.133,27.647,2.906,37.098l4.742,4.049l-55.156,64.555c-18.525,21.684-15.953,54.283,5.73,72.809
c21.674,18.525,54.277,15.973,72.799-5.701l55.154-64.537l4.725,4.043c5.322,4.536,12.213,6.766,19.168,6.222
c6.955-0.549,13.414-3.825,17.947-9.144l40.102-46.914c13.975,3.357,28.357,5.241,42.926,5.241
c54.289,0,105.656-23.65,140.904-64.895C462.634,267.892,478.185,220.026,474.332,170.706z M392.974,273.448
c-25.869,30.253-63.527,47.605-103.375,47.605c-32.262,0-63.57-11.57-88.139-32.586c-27.602-23.551-44.359-56.455-47.201-92.635
c-2.842-36.185,8.578-71.305,32.146-98.872c25.85-30.253,63.508-47.606,103.355-47.606c32.264,0,63.59,11.566,88.176,32.57
c27.584,23.568,44.342,56.472,47.166,92.65C427.962,210.743,416.542,245.858,392.974,273.448z"/>
<path d="M289.699,149.575c-19.664,0-35.654,15.988-35.654,35.617c0,19.637,15.99,35.621,35.654,35.621
c19.635,0,35.602-15.984,35.602-35.621C325.3,165.564,309.333,149.575,289.699,149.575z"/>
<path d="M382.269,202.673l-12.916-7.451c0.422-3.328,1.027-6.586,1.027-10.029c0-3.436-0.605-6.695-1.027-10.022l12.916-7.468
c3.217-1.85,5.592-4.934,6.557-8.533c0.965-3.598,0.467-7.439-1.416-10.668l-17.092-29.595c-3.854-6.718-12.467-9.03-19.215-5.159
l-12.789,7.424c-5.43-4.113-11.166-7.76-17.48-10.41V96.267c0-7.775-6.295-14.054-14.07-14.054h-34.158
c-7.775,0-14.06,6.278-14.06,14.054v14.494c-6.348,2.65-12.066,6.297-17.463,10.41l-12.836-7.424
c-3.229-1.859-7.088-2.373-10.686-1.396c-3.6,0.964-6.65,3.31-8.533,6.556l-17.074,29.595c-3.889,6.717-1.578,15.323,5.141,19.201
l12.916,7.468c-0.416,3.327-1.012,6.587-1.012,10.022c0,3.443,0.596,6.701,1.012,10.029l-12.916,7.451
c-6.719,3.877-9.029,12.483-5.141,19.219l17.074,29.595c1.883,3.229,4.934,5.591,8.533,6.557c3.598,0.958,7.457,0.46,10.686-1.398
l12.836-7.423c5.396,4.112,11.115,7.758,17.463,10.426v14.479c0,7.773,6.285,14.054,14.06,14.054h34.158
c7.775,0,14.07-6.28,14.07-14.054v-14.479c6.332-2.668,12.051-6.313,17.48-10.426l12.789,7.423c3.248,1.858,7.1,2.357,10.699,1.398
c3.6-0.966,6.684-3.328,8.516-6.557l17.092-29.595C391.304,215.173,388.988,206.561,382.269,202.673z M289.699,237.265
c-28.728,0-52.105-23.356-52.105-52.072c0-28.709,23.377-52.07,52.105-52.07c28.709,0,52.053,23.361,52.053,52.07
C341.751,213.908,318.408,237.265,289.699,237.265z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

+4 -4
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12ZM3.00683 12C3.00683 16.9668 7.03321 20.9932 12 20.9932C16.9668 20.9932 20.9932 16.9668 20.9932 12C20.9932 7.03321 16.9668 3.00683 12 3.00683C7.03321 3.00683 3.00683 7.03321 3.00683 12Z" fill="#0F0F0F"/>
<path d="M12 5C11.4477 5 11 5.44771 11 6V12.4667C11 12.4667 11 12.7274 11.1267 12.9235C11.2115 13.0898 11.3437 13.2343 11.5174 13.3346L16.1372 16.0019C16.6155 16.278 17.2271 16.1141 17.5032 15.6358C17.7793 15.1575 17.6155 14.5459 17.1372 14.2698L13 11.8812V6C13 5.44772 12.5523 5 12 5Z" fill="#0F0F0F"/>
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12ZM3.00683 12C3.00683 16.9668 7.03321 20.9932 12 20.9932C16.9668 20.9932 20.9932 16.9668 20.9932 12C20.9932 7.03321 16.9668 3.00683 12 3.00683C7.03321 3.00683 3.00683 7.03321 3.00683 12Z" fill="#0F0F0F"/>
<path d="M12 5C11.4477 5 11 5.44771 11 6V12.4667C11 12.4667 11 12.7274 11.1267 12.9235C11.2115 13.0898 11.3437 13.2343 11.5174 13.3346L16.1372 16.0019C16.6155 16.278 17.2271 16.1141 17.5032 15.6358C17.7793 15.1575 17.6155 14.5459 17.1372 14.2698L13 11.8812V6C13 5.44772 12.5523 5 12 5Z" fill="#0F0F0F"/>
</svg>

Before

Width:  |  Height:  |  Size: 860 B

After

Width:  |  Height:  |  Size: 856 B

-39
View File
@@ -1,39 +0,0 @@
@import "./base.css";
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
}
.small-input {
padding-top: 8px;
}
+14 -4
View File
@@ -1,8 +1,8 @@
/* Log Highlighting CSS Classes */
/* Base highlighting classes */
.log-highlight {
font-family: monospace;
font-size: 12px;
font-family: var(--font-mono);
font-size: var(--text-xs);
word-break: break-word;
display: inline;
@@ -33,6 +33,13 @@
color: #9ca3af;
}
/* `.log-key`/`.log-separator` are applied to generated log HTML injected via
v-html (useLogsHighlighter.ts, JsonPreview.vue), so they live in this global
stylesheet rather than any component scope. */
.log-key,
.log-separator {
font-weight: normal;
}
.log-object-brace {
color: #9ca3af;
@@ -93,6 +100,9 @@
color: #6EE7B7; /* Soft mint green */
}
.dark .log-separator {
color: #999;
}
.dark .log-number {
color: #60A5FA; /* Light blue for numbers */
@@ -174,8 +184,8 @@
/* Special classes for performance optimization */
.log-simple-text {
font-family: monospace;
font-size: 12px;
font-family: var(--font-mono);
font-size: var(--text-xs);
word-break: break-word;
}
+1 -1
View File
@@ -16,7 +16,7 @@
<OIcon :name="isDropdownOpen ? 'expand-less' : 'expand-more'" size="xs" />
</OButton>
</template>
<div class="logs-table-list min-w-[180px]">
<div class="logs-table-list min-w-45">
<slot name="dropdown" :field="field" :value="value" />
</div>
</ODropdown>
@@ -40,16 +40,9 @@ describe("AutoRefreshInterval", () => {
global: {
plugins: [i18n, mockRouter],
stubs: {
'q-btn-dropdown': {
template: '<div class="q-btn-dropdown"><slot /><slot name="label" /></div>'
},
'OIcon': {
template: '<div class="OIcon"></div>'
},
'q-btn': {
template: '<button @click="$emit(\'click\')" :disabled="disable"><slot /></button>',
props: ['disable', 'disabled']
},
OButton: {
template: '<button :data-test="$attrs[\'data-test\']" :disabled="disabled" @click="$emit(\'click\')"><slot /><slot name="label" /></button>',
props: ['variant', 'size', 'disabled', 'loading', 'active', 'block'],
@@ -63,13 +56,6 @@ describe("AutoRefreshInterval", () => {
ODropdownSeparator: {
template: '<hr />',
},
'q-tooltip': {
template: '<div class="q-tooltip"><slot /></div>'
},
'q-menu': {
template: '<div class="q-menu"><slot /></div>'
}
}
},
});
+22 -11
View File
@@ -32,7 +32,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<OIcon
name="update"
:class="isAnimating ? '![animation:rotate_2s_linear_infinite] ![transform-origin:center_center] !inline-block' : ''"
:class="isAnimating ? 'auto-refresh-icon--spinning' : ''"
size="sm"
/>
<OTooltip :content="`${t('search.autoRefresh')}: ${selectedLabel}`" />
@@ -40,7 +40,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<div class="w-75 p-2">
<div class="flex">
<div class="flex flex-col w-full p-2" style="text-align: center">
<div class="flex flex-col w-full p-2 text-center">
<OButton
data-test="logs-search-off-refresh-interval"
:variant="modelValue.toString() === '0' ? 'primary' : 'ghost'"
@@ -52,13 +52,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OButton>
</div>
</div>
<hr class="border-0 border-t border-solid border-(--o2-border) my-0" />
<hr class="border-0 border-t border-solid border-border-default my-0" />
<div v-for="(items, i) in refreshTimes" :key="'row_' + i" class="flex">
<div
v-for="(item, j) in items"
:key="'col_' + i + '_' + j"
class="flex flex-col w-1/3 p-2"
style="text-align: center"
class="flex flex-col w-1/3 p-2 text-center"
>
<OButton
:data-test="`logs-search-bar-refresh-time-${item.value}`"
@@ -100,19 +99,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
name="update"
size="sm"
:class="[
isAnimating ? '![animation:rotate_2s_linear_infinite] ![transform-origin:center_center] !inline-block' : '',
isAnimating ? 'auto-refresh-icon--spinning' : '',
isAnimating ? 'text-primary' : '',
'mr-0.5',
]"
/>
<div class="text-center text-[0.8125rem] leading-4">{{ selectedLabel }}</div>
<div class="text-center text-compact leading-4">{{ selectedLabel }}</div>
<OIcon name="arrow-drop-down" size="sm" class="ml-0.5" />
</div>
</OButton>
</template>
<div class="w-75 p-2">
<div class="flex">
<div class="flex flex-col w-full p-2" style="text-align: center">
<div class="flex flex-col w-full p-2 text-center">
<OButton
data-test="logs-search-off-refresh-interval"
:variant="modelValue.toString() === '0' ? 'primary' : 'ghost'"
@@ -134,8 +133,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div
v-for="(item, j) in items"
:key="'col_' + i + '_' + j"
class="flex flex-col w-1/3 p-2"
style="text-align: center"
class="flex flex-col w-1/3 p-2 text-center"
>
<OButton
:data-test="`logs-search-bar-refresh-time-${item.value}`"
@@ -382,7 +380,19 @@ export default defineComponent({
});
</script>
<style>
<style scoped>
/* keep(keyframes): the spinning refresh icon is used only by this component (both
the icon-only and the labelled dropdown trigger share this one class). The
`animation` is declared here rather than as a template `[animation:…]` utility
so Vue's scoped compiler renames the keyframe and this reference together.
The class lands on an OIcon root, which carries this component's scope id too.
`!important` is retained from the original `!`-prefixed utilities. */
.auto-refresh-icon--spinning {
display: inline-block !important;
transform-origin: center center !important;
animation: rotate 2s linear infinite !important;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
@@ -392,3 +402,4 @@ export default defineComponent({
}
}
</style>
+43 -212
View File
@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="relative w-full h-full flex flex-col" v-bind="$attrs">
<div
data-test="query-editor"
class="logs-query-editor flex-1 min-h-0 bg-(--o2-card-bg)"
class="logs-query-editor flex-1 min-h-0 bg-card-glass-bg"
ref="editorRef"
:id="editorId"
/>
@@ -27,8 +27,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-if="showAiIcon && !disableAi"
variant="sidebar-toggle"
size="icon-toolbar"
class="absolute! top-2 right-2 z-10 bg-(--o2-bg-primary) border border-(--o2-border-color) transition-all duration-200 hover:bg-(--color-button-outline-hover-bg) hover:border-[var(--o2-color-primary)]"
:class="nlpMode ? 'bg-[var(--o2-color-primary-light)] border-[var(--o2-color-primary)]' : ''"
class="absolute! top-2 right-2 z-10 bg-card-glass-bg border border-card-glass-border transition-all duration-200 hover:bg-button-outline-hover-bg hover:border-accent"
:class="nlpMode ? 'bg-primary-100 border-accent' : ''"
@click="toggleNlpMode"
data-test="query-editor-ai-icon-btn"
>
@@ -75,6 +75,7 @@ const loadMonaco = async () => {
import { vrlLanguageDefinition } from "@/utils/query/vrlLanguageDefinition";
import { useStore } from "vuex";
import { useTheme } from "@/composables/useTheme";
import { debounce } from "lodash-es";
import searchState from "@/composables/useLogs/searchState";
import { useNLQuery } from "@/composables/useNLQuery";
@@ -82,6 +83,7 @@ import { useI18n } from "vue-i18n";
import useNotifications from "@/composables/useNotifications";
import { getImageURL } from "@/utils/zincutils";
import { isAuthError } from "@/utils/authErrors";
import { getFontMono } from "@/utils/fonts";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
@@ -177,10 +179,10 @@ export default defineComponent({
],
setup(props, { emit }) {
const store = useStore();
const { isDark } = useTheme();
const { t } = useI18n();
const { showErrorNotification } = useNotifications();
const editorRef: any = ref();
// editor object is used to interact with the monaco editor instance
let editorObj: any = null;
// Emits the editor's content immediately instead of waiting out the change
// debounce. Assigned when the editor is created; see `commitModelChange`.
@@ -362,11 +364,11 @@ export default defineComponent({
];
watch(
() => store.state.theme,
() => isDark.value,
() => {
if (!monaco) return;
monaco.editor.setTheme(
store.state.theme == "dark" ? "myCustomDarkTheme" : "myCustomTheme",
isDark.value ? "myCustomDarkTheme" : "myCustomTheme",
);
},
);
@@ -427,7 +429,6 @@ export default defineComponent({
const currentLanguage = props.language?.toLowerCase() || "sql";
try {
// Get organization ID from store
const orgId = store.state.selectedOrganization?.identifier || "default";
// Create language-appropriate prompt
@@ -649,7 +650,11 @@ export default defineComponent({
editorObj = monaco.editor.create(editorElement as HTMLElement, {
value: props.query?.trim(),
language: props.language,
theme: store.state.theme == "dark" ? "myCustomDarkTheme" : "myCustomTheme",
// Monaco paints its own text and ignores the CSS cascade — without this it
// falls back to its built-in Menlo/Monaco/Courier New stack, which differs
// per OS and from the rest of the app.
fontFamily: getFontMono(),
theme: isDark.value ? "myCustomDarkTheme" : "myCustomTheme",
showFoldingControls: enableCodeFolding.value ? "always" : "never",
folding: enableCodeFolding.value,
wordWrap: "on",
@@ -877,11 +882,11 @@ export default defineComponent({
);
watch(
() => store.state.theme,
() => isDark.value,
() => {
if (!monaco) return;
monaco.editor.setTheme(
store.state.theme == "dark" ? "myCustomDarkTheme" : "myCustomTheme",
isDark.value ? "myCustomDarkTheme" : "myCustomTheme",
);
},
);
@@ -1174,7 +1179,7 @@ export default defineComponent({
// Computed property for AI icon based on theme
const aiIcon = computed(() => {
return store.state.theme === "dark"
return isDark.value
? getImageURL("images/common/ai_icon_dark.svg")
: getImageURL("images/common/ai_icon_gradient.svg");
});
@@ -1209,216 +1214,42 @@ export default defineComponent({
});
</script>
<style>
.monaco-editor,
.monaco-diff-editor .synthetic-focus,
.monaco-editor,
.monaco-diff-editor [tabindex="0"]:focus,
.monaco-editor,
.monaco-diff-editor [tabindex="-1"]:focus,
.monaco-editor,
.monaco-diff-editor button:focus,
.monaco-editor,
.monaco-diff-editor input[type="button"]:focus,
.monaco-editor,
.monaco-diff-editor input[type="checkbox"]:focus,
.monaco-editor,
.monaco-diff-editor input[type="search"]:focus,
.monaco-editor,
.monaco-diff-editor input[type="text"]:focus,
.monaco-editor,
.monaco-diff-editor select:focus,
.monaco-editor,
.monaco-diff-editor textarea:focus {
outline-width: 0px;
}
<style scoped>
/* keep(lib-override:monaco) — every rule below targets Monaco's own generated DOM
(.monaco-editor, .suggest-widget, and decoration classes injected via the
decorations API). These nodes are created by the library at runtime and never
carry the scoped data-v attribute, so they are unreachable from utilities and
must stay as :deep() CSS. */
.logs-query-editor .monaco-editor,
.logs-query-editor .monaco-editor .monaco-editor {
padding: 0px 0px 0px 0px !important;
.logs-query-editor :deep(.monaco-editor),
.logs-query-editor :deep(.monaco-editor .monaco-editor) {
padding: 0 !important;
--vscode-focusBorder: transparent !important;
}
.logs-query-editor .monaco-editor .editor-widget .suggest-widget,
.logs-query-editor .monaco-editor .monaco-editor .editor-widget .suggest-widget {
/* Neutralise monaco's stray focus outline / focus-border on the mount, the real
inner editor, the overflow guard and the hidden focus textarea. The last three
are monaco-generated DOM. */
.logs-query-editor,
.logs-query-editor :deep(.monaco-editor),
.logs-query-editor :deep(.overflow-guard),
.logs-query-editor :deep(.inputarea) {
outline: none !important;
--vscode-focusBorder: transparent !important;
}
.logs-query-editor :deep(.monaco-editor .editor-widget .suggest-widget),
.logs-query-editor :deep(.monaco-editor .monaco-editor .editor-widget .suggest-widget) {
z-index: 9999;
display: flex !important;
visibility: visible !important;
}
.generate-sql-button:hover:not(.disabled):not([disabled]):not(:disabled) {
background: linear-gradient(135deg, #7c3aed 0%, #db2777 100%) !important;
box-shadow: 0 0.375rem 1.25rem 0 rgba(139, 92, 246, 0.4) !important;
transform: translateY(-0.0625rem) !important;
}
.generate-sql-button:active:not(.disabled):not([disabled]):not(:disabled) {
transform: translateY(0) !important;
box-shadow: 0 0.125rem 0.625rem 0 rgba(139, 92, 246, 0.3) !important;
}
/* Fade transition for button appearance */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Streaming preview card - O2 AI Assistant message style with purple border */
.streaming-preview-card {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 56.25rem; /* 900px - matches O2 AI Assistant max-width */
max-width: calc(100% - 2rem);
max-height: 31.25rem;
background: var(--o2-card-bg);
border-radius: 0.5rem; /* 8px - matches O2 message border-radius */
border: 2px solid #8b5cf6; /* O2 AI Assistant purple border */
padding: 0.75rem; /* 12px - matches O2 message padding */
z-index: 99;
overflow: hidden;
}
/* Light mode shadow - matches O2 AI Assistant with purple glow */
.light-mode .streaming-preview-card {
box-shadow: 0 0.25rem 1rem 0 rgba(139, 92, 246, 0.2);
}
/* Dark mode shadow - matches O2 AI Assistant with purple glow */
.dark-mode .streaming-preview-card {
box-shadow: 0 0.25rem 1rem 0 rgba(139, 92, 246, 0.3);
}
/* Streaming preview content - O2 AI Assistant text-block style */
.streaming-preview-content {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
font-size: 0.875rem;
line-height: 1.6; /* Better readability for text content */
color: var(--o2-text-primary);
margin: 0;
padding: 1rem;
overflow-y: auto;
max-height: 30rem;
max-width: 100%;
}
/* Generating text with dynamic message */
.generating-text {
font-size: 0.9375rem; /* 15px */
font-weight: 500;
color: var(--o2-text-primary);
}
/* Animated dots - ellipsis animation using pseudo-element */
.animated-dots::after {
content: "";
animation: ellipsis 1.5s infinite;
display: inline-block;
width: 1.5em;
text-align: left;
font-size: inherit;
font-weight: inherit;
font-family: inherit;
color: inherit;
line-height: inherit;
}
@keyframes ellipsis {
0% {
content: "";
}
25% {
content: ".";
}
50% {
content: "..";
}
75%,
100% {
content: "...";
}
}
/* Code blocks within streaming preview */
.streaming-preview-content :deep(pre),
.streaming-preview-content :deep(code) {
font-family: "Monaco", "Menlo", "Ubuntu Mono", "Courier New", monospace;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
line-height: 1.4;
padding: 0.5rem;
margin: 0.25rem 0;
border-radius: 0.25rem;
display: block;
max-width: 100%;
overflow-x: auto;
}
/* Lists within streaming preview */
.streaming-preview-content :deep(ol) {
list-style-type: decimal;
padding-left: 1.5em;
margin: 0.5em 0;
}
.streaming-preview-content :deep(ul) {
list-style-type: disc;
padding-left: 1.5em;
margin: 0.5em 0;
}
.streaming-preview-content :deep(li) {
margin: 0.25em 0;
}
/* Paragraphs within streaming preview */
.streaming-preview-content :deep(p) {
margin: 0.5em 0;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
max-width: 100%;
}
/* Slide up transition for streaming preview - centered */
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.3s ease;
}
.slide-up-enter-from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.95);
}
.slide-up-leave-to {
opacity: 0;
transform: translate(-50%, -50%) scale(0.98);
}
@keyframes typing-cursor {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.85;
}
}
.highlight-error {
background-color: rgba(255, 0, 0, 0.1);
/* Error decoration — class name is handed to monaco.deltaDecorations(), so the
element only ever exists inside Monaco's view-lines. */
.logs-query-editor :deep(.highlight-error) {
background-color: color-mix(in srgb, var(--color-status-negative) 10%, transparent);
text-decoration: underline;
text-decoration-color: red;
text-decoration-color: var(--color-status-negative);
}
</style>
+5 -5
View File
@@ -153,7 +153,7 @@ const joinSlack = () => {
<!-- Header: Slack badge, title to its right, close button on the far right -->
<div class="flex items-start gap-3">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary-50"
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-default bg-primary-50"
aria-hidden="true"
>
<svg
@@ -201,7 +201,7 @@ const joinSlack = () => {
<p
data-test="community-slack-invite-description"
class="text-[var(--o2-text-secondary)]"
class="text-text-secondary"
>
{{ t("communitySlackInvite.description") }}
</p>
@@ -226,7 +226,7 @@ const joinSlack = () => {
>
<path d="M20 6 9 17l-5-5" />
</svg>
<span class="text-[var(--o2-text-secondary)]">{{
<span class="text-text-secondary">{{
benefit
}}</span>
</li>
@@ -235,14 +235,14 @@ const joinSlack = () => {
<!-- Social proof — generic avatar stack, no fabricated counts -->
<div
data-test="community-slack-invite-members"
class="flex items-center gap-3 rounded-lg bg-surface-panel px-3 py-2.5"
class="flex items-center gap-3 rounded-default bg-surface-panel px-3 py-2.5"
>
<div class="flex items-center" aria-hidden="true">
<span
v-for="(bg, i) in avatarBgClasses"
:key="i"
:class="[
'flex h-7 w-7 items-center justify-center rounded-full text-white ring-2 ring-surface-panel',
'flex h-7 w-7 items-center justify-center rounded-full text-text-inverse ring-2 ring-surface-panel',
bg,
i > 0 ? '-ml-2' : '',
]"
+1 -1
View File
@@ -75,7 +75,7 @@ const ODialogStub = {
`,
};
// Stub OBanner — the component ConfirmDialog uses OBanner (not q-banner).
// Stub OBanner — the component ConfirmDialog uses OBanner (not a raw banner element).
// Render it with role="alert" (matching OBanner's warning variant behaviour)
// so tests can locate it semantically via role query.
const OBannerStub = {
+1 -1
View File
@@ -15,7 +15,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div class="relative rounded-lg overflow-hidden copy-content-block" :class="store.state.theme === 'dark' ? 'bg-[rgba(255,255,255,0.06)]' : 'bg-[rgba(0,0,0,0.05)]'">
<div class="relative rounded-default overflow-hidden copy-content-block bg-surface-subtle">
<div class="absolute top-2 right-2 z-10">
<OButton
data-test="rum-copy-btn"
@@ -39,14 +39,7 @@ describe('CustomDateTimePicker.vue', () => {
props,
global: {
plugins: [currentStore],
stubs: {
'q-btn': true,
'q-menu': true,
'q-tab-panels': true,
'q-tab-panel': true,
'q-input': true,
'q-select': true
}
stubs: {}
}
});
};
@@ -231,24 +224,24 @@ describe('CustomDateTimePicker.vue', () => {
});
describe('Theme and Styling', () => {
it('computedClass returns dark mode class for dark theme', () => {
it('computedClass returns semantic surface class for dark theme', () => {
wrapper = createWrapper(
{ changeStyle: true },
{ changeStyle: true },
{ theme: 'dark' }
);
const vm = wrapper.vm as any;
expect(vm.computedClass).toBe('bg-[#2a2828]! text-white!');
expect(vm.computedClass).toBe('bg-surface-base!');
});
it('computedClass returns light mode class for light theme', () => {
it('computedClass returns semantic surface class for light theme', () => {
wrapper = createWrapper(
{ changeStyle: true },
{ changeStyle: true },
{ theme: 'light' }
);
const vm = wrapper.vm as any;
expect(vm.computedClass).toBe('bg-white!');
expect(vm.computedClass).toBe('bg-surface-base!');
});
it('computedClass returns empty string when changeStyle is false', () => {
+9 -15
View File
@@ -8,7 +8,7 @@
}"
data-test="date-time-btn"
variant="outline"
class="h-full rounded-[3px] py-0 px-[5px] text-xs min-w-auto bg-[rgba(89,96,178,0.2)]!"
class="h-full rounded-default py-0 px-1.25 text-xs min-w-auto bg-[rgba(89,96,178,0.2)]!"
:class="changeStyle ? computedClass : 'h-8!'"
:disabled="isFirstEntry"
>
@@ -23,13 +23,13 @@
</template>
</OButton>
</template>
<div class="date-time-dialog w-[341px] z-[10001] max-h-[600px]">
<div class="date-time-dialog w-85.25 z-[10001] max-h-150">
<div class="flex justify-between">
<OTabPanels v-model="picker.activeTab">
<OTabPanel name="relative">
<div class="date-time-table relative flex flex-col">
<div
class="relative-row px-3 py-2 flex items-center border-b border-[var(--o2-border)]"
class="relative-row [&>*]:mr-1.5 px-3 py-2 flex items-center border-b border-border-default"
v-for="(period, periodIndex) in relativePeriods"
:key="'date_' + periodIndex"
>
@@ -41,17 +41,18 @@
<OButton
:data-test="`date-time-relative-${item}-${period.value}-btn`"
variant="ghost"
class="h-8! w-8! font-bold! disabled:opacity-35"
:class="
isSelected(item, period.value)
? 'rp-selector-selected'
: 'rp-selector'
? 'bg-button-primary! text-button-primary-foreground!'
: 'bg-[color-mix(in_srgb,var(--color-text-heading)_7%,transparent)]!'
"
@click="setRelativeDate(period, item)"
>{{ item }}</OButton
>
</div>
</div>
<div class="relative-row px-3 py-2 flex items-center border-b border-[var(--o2-border)]">
<div class="relative-row [&>*]:mr-1.5 px-3 py-2 flex items-center border-b border-border-default">
<div class="text-sm font-semibold min-w-18.75">Custom</div>
<div class="flex gap-2">
<div class="flex flex-col w-20">
@@ -92,7 +93,6 @@ import OInput from "@/lib/forms/Input/OInput.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import { ref, reactive, watch, computed } from "vue";
import { useStore } from "vuex";
// Define props to receive the value (offset) from parent
const props = defineProps({
@@ -113,8 +113,6 @@ const props = defineProps({
const emit = defineEmits(["update:modelValue"]);
const store = useStore();
const picker = reactive({
activeTab: "relative",
showMenu: false,
@@ -192,7 +190,7 @@ const setRelativeDate = (period, item) => {
emit("update:modelValue", `${item}${period.value}`);
};
// Function to update custom period when selecting from q-select
// Function to update custom period when selecting from the select
const updateCustomPeriod = (newPeriod) => {
picker.data.selectedDate.relative.label = getPeriodLabelFromValue(newPeriod);
emit(
@@ -227,11 +225,7 @@ const getPeriodLabel = () => {
};
const computedClass = computed(() => {
return props.changeStyle
? store.state.theme === "dark"
? "bg-[#2a2828]! text-white!"
: "bg-white!"
: "";
return props.changeStyle ? "bg-surface-base!" : "";
});
</script>
@@ -268,7 +268,9 @@ describe("DatabaseDeprecationBanner", () => {
await wrapper.vm.$nextTick();
expect(wrapper.find(".light-stream-container").exists()).toBe(true);
// Theming is now global (`.dark` on <html>), not a per-element class, so
// the banner renders the same regardless of theme.
expect(wrapper.find(".feature-card").exists()).toBe(true);
});
it("should apply dark theme class", async () => {
@@ -289,7 +291,9 @@ describe("DatabaseDeprecationBanner", () => {
await wrapper.vm.$nextTick();
expect(wrapper.find(".dark-stream-container").exists()).toBe(true);
// Theming is now global (`.dark` on <html>), not a per-element class, so
// the banner renders the same regardless of theme.
expect(wrapper.find(".feature-card").exists()).toBe(true);
});
it("should handle invalid localStorage data gracefully", async () => {

Some files were not shown because too many files have changed in this diff Show More