Files
openobserve/web/src/composables/useStickyColumns.ts
T
1c849a9c71 refactor(web): migrate templates + shadows to tokens, delete the debt baseline (#13769)
## What

Deletes `web/scripts/design-debt-baseline.json` — **not by relaxing the
rules, by fixing the debt**. Every design-token category is now
zero-tolerance, SCSS is gone from the app, and 144 hand-written shadow
values collapse into one scale.

## The guard was under-reporting (three separate holes)

1. **Comment contamination.** `check-design-consistency.mjs` fed comment
text into its exemption checks, so a `keep()` comment that merely
*named* `:deep(` / `::after` / `@keyframes` silenced the rule after it —
and a `;` or `{` inside a comment corrupted the offsets, so stripping
comments afterwards couldn't help. Comments are now blanked
(offset-preserving) before the scan. **Uncovered 9 occurrences in 6
files.** Real debt was 110, not 101.
2. **`arbShadow` only scanned the template.** `box-shadow:` in a
`<style>` block and `boxShadow:` in a JS/`:style` object were invisible
— 40 literals sat there while the guard reported zero. It now scans all
three syntaxes across `.vue` and `.ts`.
3. **`rawVarInTemplate` missed the fallback form.**
`border-(--color-x,rgba(…))` and `bg-[var(--color-x,#fff)]` slipped
through. The token is always defined, so the fallback can never fire —
it only hid the read.

## stylelint had never run on 14 files

`postcss-scss` isn't installed, so `postcss-html` **silently skipped**
every `<style lang="scss">` block. The CI step named *"Lint styles
(stylelint — bans legacy --o2-* tokens)"* was a no-op for those files —
including `color-no-hex` and the `.body--dark` selector ban. All 14 are
plain CSS now and `vue/block-lang` errors on `scss`/`sass`/`less`, so it
cannot silently recur.

## What moved

| | before | after |
|---|---|---|
| `rawVarInComponent` (in `<style>`) | 110 | **0** |
| template `x-[var(--color-*)]`, utility exists | 225 | **0** |
| template reads, no utility | 27 | **0** |
| `style` / `:style` colour literals | 29 | **0** |
| `color-mix(token N%, transparent)` | 160 | **0** |
| hand-written shadows | 233 (144 distinct) | **0** |
| SFCs using SCSS | 14 | **0** |

Guard categories: 22 → 24. `rawVarInTemplate` and `arbShadow` are new;
`arbPx` was deleted because main's `local/no-hardcoded-px` ESLint rule
(#13541) now owns px across every file type.

## Shadows are a system now

Stripped of colour, the 144 values were the same handful of geometries
rewritten in px vs rem, with and without an explicit `0` spread.

```
--shadow-xs / sm / md / lg              elevation
--shadow-sticky-left / -right           directional (sticky columns)
--shadow-rail                           inset status rail
--shadow-glow-sm / -md / -lg            coloured, 3 intensities
--shadow-scroll-top / -bottom / -both   scroll affordance
--shadow-ring-hairline                  1px ring where a utility can't reach
--shadow-ai-focus                       AI composer focus glow (flips in dark)
```

Colour is a **separate axis**. Tailwind rewrites a `--shadow-*` token as
`<geometry> var(--tw-shadow-color, <default>)` — verified by compiling a
probe, and it holds for multi-layer tokens — so a template writes
`shadow-md shadow-ai-accent/35`. A plain stylesheet can't add a utility,
so the role and glow tokens read `--glow-color`; that keeps the pipeline
canvas colour-coding nodes by type from CSS and lets the status rail be
painted per row from JS.

Also reclassified: `0 0 0 2px <colour>` is a **ring**, not a shadow, and
`0 1px 0 0 <colour>` is a **border**.

## color-mix, analysed by intent

All 331 occurrences were classified rather than treated as one problem:

- **160** mixed a token with `transparent` — only an alpha change, so
it's Tailwind's `/N` modifier written the long way (verified: `/N`
compiles to `color-mix(in oklab, <token> N%, transparent)`, and mixing
with `transparent` is premultiplied, so the interpolation space doesn't
matter)
- **81** blended two colours — a derived colour, so it's a token now
(~50 minted)
- **75** were shadow/ring geometry, handled by the scale
- **8 gradients + 1 `calc()`-driven percentage** genuinely have no
utility form and remain; the guard exempts gradient/mask/filter
contexts, matching what the style-block counter already exempts

## Merge with main

Main was running the same migration in parallel (**#13541 "px to rem"**
removed all SCSS, added `no-hardcoded-px`, and built its own shadow
scale), so the merge collided in 68 files / 155 hunks. Resolved by
taking main's source files and re-applying this branch on top, with the
guards driving convergence (429 → 0) rather than hand-resolving hunks.

On shadows, **main's structure wins** — geometry in `semantic.css`
`@theme`, colours as `--shadow-*-c*` sub-tokens, wrapped in its px
disable; its `--shadow-sm/md/lg`, `--shadow-scroll-*` and
`--shadow-glow` keep their definitions. This branch adds what main
didn't have: `xs`, the directional/inset roles, and the
elevation-matched coloured glows.

## New tokens

No new *colours* — each names a value a component already hardcoded,
because a registered palette utility (`text-orange-500`) is banned by
`rawPalette`, so "just register the ramp" was never available.

`--color-splitter-hover`, `--color-status-flaky` (+`-text`),
`--color-chat-bubble-user-border`, `--color-thread-avatar-bg`,
`--color-thread-accent-strong`, `--color-menu-badge-ring`,
`--color-progress-track-fill`, `--color-ingest-accent-*` (5), ~50
derived blend tokens, plus `--color-border-strong` /
`--color-border-subtle` / `--color-warning-300` registered for utility
use.

## Visual deltas to review

Everything else is value-preserving. These are not:

- **4 hover accents** move from a fixed `primary-600` to `accent` — 600
→ 400 **in dark only** (2 chevrons, an empty-state icon fill, one link)
- **sticky table header** in `TraceDetailsSidebar` was a fixed light
grey that never flipped; now `border-default`, so it themes
- **shadow canonicalization** snaps near-identical geometries together
and moves ~40 hardcoded `rgba(0,0,0,x)` onto the scale's token colour

Highest-surface single file: `AlertSettingsHelpDrawer.vue` — a whole
style block deleted with ~44 class names moved into template utilities.

## Verification

`lint:design` · `lint:tokens` · `lint:token-purity` · `lint:styles` ·
`format:check` · `lint:ci` · `vue-tsc` — all clean.
**94 spec files covering every file this branch touches: 4395 passing, 0
failing.**

Not run: the full 1236-file unit suite, Playwright/E2E, and any browser
visual check.

---------

Co-authored-by: ktx-vaidehi <vaidehi.akhani@kiara.tech>
Co-authored-by: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com>
Co-authored-by: ktx-abhay <abhay.padamani@kiara.tech>
Co-authored-by: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com>
Co-authored-by: Bhargav <BJP232004@GMAIL.COM>
2026-08-17 11:46:07 +00:00

206 lines
7.4 KiB
TypeScript

// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
import { chartColor } from "@/utils/chartTheme";
export function useStickyColumns(props: any, store: any) {
const stickyColumnOffsets = ref<{ [key: string]: number }>({});
let styleElement: HTMLStyleElement | null = null;
// Unique ID to scope injected CSS so multiple panels don't override each other
const tableId = `sticky-${Math.random().toString(36).slice(2)}`;
// Watch for columns changes to update sticky offsets
watch(
() => props.columns,
(columns: any[]) => {
const offsets: { [key: string]: number } = {};
let cumulativeWidth = 0;
if (columns) {
columns.forEach((col: any, idx: number) => {
// Add index attribute for CSS targeting
col.__colIndex = idx;
if (col.sticky) {
offsets[col.name] = cumulativeWidth;
cumulativeWidth += col.width ? parseInt(String(col.width)) : 100;
}
});
}
stickyColumnOffsets.value = offsets;
},
{ immediate: true, deep: true },
);
const getStickyColumnStyle = (column: any) => {
if (!column?.sticky) return {};
// Get the precomputed left offset for this column
const leftOffset = stickyColumnOffsets.value[column.name] ?? 0;
return {
position: "sticky",
left: `${leftOffset}px`,
"z-index": 2,
"background-color": chartColor("--color-surface-base"),
"box-shadow": "var(--shadow-sticky-left)",
};
};
const updateStickyColumnStyles = () => {
// Remove old style element if it exists
if (styleElement?.parentNode) {
styleElement.parentNode.removeChild(styleElement);
}
// Create new style element with current offsets
styleElement = document.createElement("style");
styleElement.setAttribute("data-sticky-styles", "true");
const columns = (props.columns || []) as any[];
const bgColor = chartColor("--color-surface-base");
let css = "";
const stickyColTotals = !!props.stickyColTotals;
const TOTAL_COL_WIDTH = 150;
const scope = `.my-sticky-virtscroll-table[data-sticky-id="${tableId}"]`;
// Shadow constants — right-sticky uses inset shadow to match TableRenderer scoped style
const shadowRight = "var(--shadow-sticky-left)";
const shadowLeft = "var(--shadow-sticky-right)";
const shadowBoth = `${shadowRight}, ${shadowLeft}`;
// Generate CSS rules for each column position
columns.forEach((col: any, colIndex: number) => {
if (col.sticky) {
const offset = stickyColumnOffsets.value[col.name] ?? 0;
// Left-sticky: shadow on right. If also right-sticky (middle column), shadow on both.
const shadow = col._isTotalColumn ? shadowBoth : shadowRight;
css += `
${scope} thead tr th:nth-child(${colIndex + 1}) {
position: sticky !important;
left: ${offset}px !important;
z-index: 4 !important;
background-color: var(--color-sticky-col-header-bg) !important;
box-shadow: ${shadow} !important;
}
${scope} tbody td:nth-child(${colIndex + 1}).sticky-column {
left: ${offset}px !important;
}
`;
}
// Right-sticky total columns (for standard single-row headers)
if (stickyColTotals && col._isTotalColumn) {
const rightOffset = (col._totalColRightIndex ?? 0) * TOTAL_COL_WIDTH;
// Right-sticky: shadow on left. If also left-sticky (middle column), shadow on both.
const shadow = col.sticky ? shadowBoth : shadowLeft;
css += `
${scope} thead tr:first-child th:nth-child(${colIndex + 1}) {
position: sticky !important;
right: ${rightOffset}px !important;
z-index: 4 !important;
min-width: ${TOTAL_COL_WIDTH}px !important;
background-color: var(--color-sticky-col-header-bg) !important;
box-shadow: ${shadow} !important;
}
`;
}
});
// Add base styling for all sticky columns
css =
`
/* Left-sticky body cells: shadow on right */
${scope} tbody td.sticky-column {
position: sticky !important;
z-index: 2 !important;
box-shadow: var(--shadow-sticky-left) !important;
}
/* Right-sticky total column body cells: shadow on the left edge. The
original drew an INSET shadow inside the column; the token casts
outward onto the scrolling cells instead. Same boundary, opposite
side, and it matches what OTable's sticky cells already do. */
${scope} tbody td.pivot-total-col {
position: sticky !important;
z-index: 2 !important;
background-color: ${bgColor} !important;
box-shadow: var(--shadow-sticky-right) !important;
}
/* Middle sticky body cells: outward on both edges */
${scope} tbody td.sticky-column.pivot-total-col {
box-shadow: var(--shadow-sticky-left), var(--shadow-sticky-right) !important;
}
/* Sticky total row (bottom sticky). --shadow-sticky-footer, NOT
--shadow-scroll-bottom: a pinned footer casts OUTWARD and UPWARD onto
the rows it overlaps. scroll-bottom is an INSET bottom-edge gradient
meaning "content continues below" — a different affordance entirely. */
${scope}.pivot-sticky-totals .pivot-sticky-total-row td {
position: sticky !important;
bottom: 0 !important;
z-index: 2 !important;
background-color: ${bgColor} !important;
font-weight: bold !important;
box-shadow: var(--shadow-sticky-footer) !important;
}
/* Corner: sticky total row + sticky total column intersection */
${scope}.pivot-sticky-totals .pivot-sticky-total-row td.pivot-total-col {
z-index: 5 !important;
}
/* Column-specific positions for headers and body cells */
` + css;
styleElement.textContent = css;
document.head.appendChild(styleElement);
};
// Watch sticky column offsets and update styles
watch(() => stickyColumnOffsets.value, updateStickyColumnStyles, {
deep: true,
});
// Watch theme changes and update styles
watch(() => store.state.theme, updateStickyColumnStyles);
onMounted(() => {
// Defer to next frame so the synchronous style injection from the watch
// chain (which already ran during setup) doesn't invalidate the style cache
// right before other code reads layout properties (e.g. getRect).
requestAnimationFrame(() => {
updateStickyColumnStyles();
});
});
onBeforeUnmount(() => {
if (styleElement?.parentNode) {
styleElement.parentNode.removeChild(styleElement);
}
});
return {
stickyColumnOffsets,
getStickyColumnStyle,
tableId,
};
}