Skip to content

Commit 76f1026

Browse files
committed
align component styles with css layers and root class merging
1 parent 3b5b7f0 commit 76f1026

13 files changed

Lines changed: 179 additions & 250 deletions

spx-gui/AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ When working with backend unique string identifiers such as `username`, project
9494

9595
* Keep `src/app.css` limited to Tailwind entry setup, theme bridge, and rare project-wide utilities.
9696
* Keep `src/components/ui/global.css` and `src/components/ui/reset.css` as the base reset/foundation layer (Tailwind preflight stays disabled).
97+
* The global CSS layer order is `theme, base, naive-ui, components, utilities`, declared in `index.html`.
9798
* Keep `--ui-*` tokens as the source of truth.
9899
* In Tailwind classes, prefer bridged semantic tokens (for example `text-text`, `text-title`, `bg-primary-100`).
99100
* In local CSS, prefer direct `--ui-*` variables instead of bridged Tailwind variables.
@@ -119,7 +120,8 @@ When working with backend unique string identifiers such as `username`, project
119120
* For root-class overrides and utility conflicts:
120121
- For business components, external root `class` overrides are allowed by default. If utility conflicts need an explicit winner, prefer adding Tailwind's important modifier at the usage site (for example `rounded-md!`, `w-32!`) instead of expanding the component API. This keeps intent explicit, usage concise, and matches the fact that business components rarely need nested override chains.
121122
- For most UI components, `twMerge` and `@layer components` are set up so external utilities or custom classes can override root classes in the common case without special handling, though edge cases can still exist.
122-
- For the Naive UI-root components listed in `src/components/ui/README.md`, avoid relying on external `class` for styling-critical overrides when possible; the final result is often hard to predict.
123+
- For the Naive UI-root components listed in `src/components/ui/README.md`, Naive UI defaults live in the `naive-ui` layer and our authored UI styles live in the `components` layer, so component-layer rules have higher cascade priority on the same element/property pair.
124+
- Even so, those Naive UI-root components still are not identical to DOM-root utility wrappers. Treat them as component-specific: simple root overrides are often fine, while deeper visual changes may still need wrapper layout control, explicit props, or Naive UI theme overrides.
123125
* Avoid non-equivalent Tailwind simplifications for flex values. In particular, `flex: 1 1 0` is not equivalent to Tailwind `flex-1` (`flex: 1 1 0%`), so do not simplify between them unless the layout behavior has been verified. Likewise, do not simplify `flex: 0 0 auto` to `shrink-0`; use the equivalent `flex-none` when that shorthand is desired.
124126
* Prefer `style` / `:style` for one-off values when clearer than Tailwind arbitrary utilities. For example, prefer `style="box-shadow: 0 24px 32px -16px rgba(0, 0, 0, 0.1)"` over a long arbitrary utility such as `shadow-[0_24px_32px_-16px_rgba(0,0,0,0.1)]`.
125127
* For important/non-obvious background assets, prefer TS imports and inline `backgroundImage` binding.

spx-gui/src/components/ui/README.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@
2222
- For most non-Naive UI components, prefer Tailwind utilities for local structure, layout, and surface styling
2323
- Local authored style blocks in the UI package use plain CSS; do not reintroduce SCSS
2424
- Keep complex selectors, animations, third-party/global overrides, and other readability-sensitive rules in plain CSS
25+
- The global CSS layer order is `theme, base, naive-ui, components, utilities` and is declared in `spx-gui/index.html`
2526

2627
- Root class handling
2728

28-
- Current cases fall into three buckets: DOM-root components with merged utilities, single-root semantic wrappers that rely on inherited classes, and Naive UI-root wrappers whose final class priority can be harder to predict
29-
- If the root is a normal DOM element and the component already owns root utility classes, use `cn(..., attrs.class)` so external root classes are merged with `tailwind-merge`
29+
- Current cases fall into three buckets: DOM-root components with merged utilities, single-root semantic wrappers that rely on inherited classes, and Naive UI-root wrappers whose root-level styling behavior still needs component-specific judgment
30+
- If the root is a normal DOM element and the component already owns root utility classes, prefer an explicit `class?: ClassValue` prop and merge it with `cn(..., props.class ?? null)` so external root classes participate in `tailwind-merge`
3031
- If the component is a single-root wrapper whose styling still depends on semantic hook classes in `@layer components`, prefer Vue's default root attr/class/style inheritance unless explicit root attr control is needed
31-
- If the rendered root is primarily a Naive UI component, do not assume external root classes will override reliably. Naive UI's internal styles are not layered with our component styles, so their priority can be higher and the final visual result may be hard to predict
32+
- If the rendered root is primarily a Naive UI component, remember that Naive UI styles live in the `naive-ui` layer while our authored UI styles live in `@layer components`, so component-layer rules have higher cascade priority than Naive UI defaults on the same element/property pair
33+
- Even so, Naive UI-root components still do not behave exactly like DOM-root utility wrappers: some visual details live on internal child nodes, teleported content, or Naive UI theme tokens instead of the exposed root element
3234
- Current Naive UI-root components that need extra care are:
3335

3436
- `UIDropdown``NPopover`
@@ -42,12 +44,14 @@
4244
- `UIRadio``NRadio`
4345
- `UIRadioGroup``NRadioGroup`
4446
- `UIForm``NForm`
47+
- `UITextInput``NInput`
48+
- `UINumberInput``NInputNumber`
4549
- `UITimeline``NTimeline`
4650
- `UITimelineItem``NTimelineItem`
4751

48-
- For all UI components, an external `class` can still be passed, but do not rely on it for styling-critical behavior. Prefer an outer layout wrapper, explicit component props, or a dedicated API extension when finer control is needed
49-
- For the Naive UI-root components listed above, root-level visual overrides from an external `class` are especially hard to predict. Prefer an outer layout wrapper or Naive UI theme overrides instead of assuming the component root will restyle reliably
50-
- `UIDivider`, `UIFormItem`, `UILoading`, `UITextInput`, and `UINumberInput` also use Naive UI internally, but they expose a wrapper DOM root; external `class` lands on that wrapper instead of the inner Naive UI control
52+
- For all UI components, an external `class` can still be passed, but prefer explicit component props or a dedicated API extension for styling-critical behavior
53+
- For the Naive UI-root components listed above, simple root-level overrides are generally workable because the `components` layer sits above `naive-ui`, but do not assume every visual change should come from the root `class`. For deeper visual changes, prefer an outer layout wrapper, explicit props, or Naive UI theme overrides
54+
- `UIDivider`, `UIFormItem`, and `UILoading` also use Naive UI internally, but they expose a wrapper DOM root; external `class` lands on that wrapper instead of the inner Naive UI control
5155
- This is primarily a component authoring/maintenance concern. The long-term goal is to keep these wrappers easy to consume so business code does not need to carry much Naive UI-specific styling knowledge
5256

5357
### Usage

spx-gui/src/components/ui/UIButton.vue

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -305,13 +305,15 @@ defineExpose({
305305
</button>
306306
</template>
307307

308-
<style scoped>
309-
.ui-button-root:hover:enabled:not(:active) .ui-button-content {
310-
background-color: var(--ui-button-hover-bg-color);
311-
}
308+
<style>
309+
@layer components {
310+
.ui-button-root:hover:enabled:not(:active) .ui-button-content {
311+
background-color: var(--ui-button-hover-bg-color);
312+
}
312313
313-
.ui-button-root[data-variant='shadow']:enabled:active .ui-button-content,
314-
.ui-button-root[data-variant='shadow'][data-loading='true'] .ui-button-content {
315-
box-shadow: none;
314+
.ui-button-root[data-variant='shadow']:enabled:active .ui-button-content,
315+
.ui-button-root[data-variant='shadow'][data-loading='true'] .ui-button-content {
316+
box-shadow: none;
317+
}
316318
}
317319
</style>

spx-gui/src/components/ui/UINumberInput.vue

Lines changed: 19 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,28 @@
11
<template>
2-
<div :class="rootClass" :style="rootStyle">
3-
<NInputNumber
4-
ref="nInput"
5-
v-bind="inputAttrs"
6-
class="ui-number-input w-full min-w-0"
7-
:placeholder="placeholder || ''"
8-
:show-button="false"
9-
:value="value"
10-
:disabled="disabled"
11-
:min="min"
12-
:max="max"
13-
@update:value="(v) => emit('update:value', v)"
14-
>
15-
<template v-if="slots.prefix != null" #prefix>
16-
<slot name="prefix"></slot>
17-
</template>
18-
<template v-if="slots.suffix != null" #suffix>
19-
<slot name="suffix"></slot>
20-
</template>
21-
</NInputNumber>
22-
</div>
2+
<NInputNumber
3+
ref="nInput"
4+
class="ui-number-input"
5+
:placeholder="placeholder || ''"
6+
:show-button="false"
7+
:value="value"
8+
:disabled="disabled"
9+
:min="min"
10+
:max="max"
11+
@update:value="(v) => emit('update:value', v)"
12+
>
13+
<template v-if="!!slots.prefix" #prefix>
14+
<slot name="prefix"></slot>
15+
</template>
16+
<template v-if="!!slots.suffix" #suffix>
17+
<slot name="suffix"></slot>
18+
</template>
19+
</NInputNumber>
2320
</template>
2421

2522
<script setup lang="ts">
26-
import { computed, onMounted, ref, type StyleValue, useAttrs, useSlots } from 'vue'
23+
import { onMounted, ref, useSlots } from 'vue'
2724
import { NInputNumber } from 'naive-ui'
2825
29-
import { cn, type ClassValue } from './utils'
30-
31-
defineOptions({
32-
inheritAttrs: false
33-
})
34-
3526
const props = defineProps<{
3627
value: number | null
3728
disabled?: boolean
@@ -45,14 +36,7 @@ const emit = defineEmits<{
4536
'update:value': [number | null]
4637
}>()
4738
48-
const attrs = useAttrs()
4939
const slots = useSlots()
50-
const rootClass = computed(() => cn('w-full min-w-0 rounded-md', attrs.class as ClassValue))
51-
const rootStyle = computed(() => attrs.style as StyleValue)
52-
const inputAttrs = computed(() => {
53-
const { class: _class, style: _style, ...rest } = attrs
54-
return rest
55-
})
5640
5741
// It's wierd that the prop `autofocus` of `NInput` does not work as expected, so we handle it manually.
5842
const nInput = ref<InstanceType<typeof NInputNumber> | null>(null)
Lines changed: 56 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,42 @@
1+
<!-- TODO: Wrap it with a native node ? -->
12
<template>
2-
<div :class="rootClass" :style="rootStyle">
3-
<NInput
4-
ref="nInput"
5-
v-bind="inputAttrs"
6-
class="ui-text-input w-full min-w-0"
7-
:class="[`color-${color}`, `ui-input-size-${size}`]"
8-
:placeholder="placeholder || ''"
9-
:value="value"
10-
:type="type"
11-
:disabled="disabled"
12-
:readonly="readonly"
13-
:resizable="false"
14-
@update:value="(v) => emit('update:value', v)"
15-
>
16-
<template v-if="slots.prefix != null" #prefix>
17-
<slot name="prefix"></slot>
18-
</template>
19-
<template v-if="(value && clearable) || slots.suffix != null" #suffix>
20-
<div
21-
v-if="value && clearable"
22-
class="-mr-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full text-grey-800 transition-colors duration-200 hover:bg-grey-400 active:bg-grey-500"
23-
@click="emit('update:value', '')"
24-
>
25-
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
26-
<path
27-
d="M6.70713 5.99999L9.35363 3.35347C9.54913 3.15847 9.54913 2.8415 9.35363 2.6465C9.15813 2.451 8.84212 2.451 8.64663 2.6465L6.00013 5.29299L3.35363 2.6465C3.15813 2.451 2.84212 2.451 2.64662 2.6465C2.45112 2.8415 2.45112 3.15847 2.64662 3.35347L5.29312 5.99999L2.64662 8.6465C2.45112 8.8415 2.45112 9.15847 2.64662 9.35347C2.74412 9.45097 2.87213 9.49999 3.00013 9.49999C3.12813 9.49999 3.25613 9.45097 3.35363 9.35347L6.00013 6.70699L8.64663 9.35347C8.74412 9.45097 8.87213 9.49999 9.00013 9.49999C9.12813 9.49999 9.25613 9.45097 9.35363 9.35347C9.54913 9.15847 9.54913 8.8415 9.35363 8.6465L6.70713 5.99999Z"
28-
fill="currentColor"
29-
/>
30-
</svg>
31-
</div>
32-
<slot name="suffix"></slot>
33-
</template>
34-
</NInput>
35-
</div>
3+
<NInput
4+
ref="nInput"
5+
class="ui-text-input"
6+
:class="[`ui-text-input-color-${color}`, `ui-text-input-size-${size}`]"
7+
:placeholder="placeholder || ''"
8+
:value="value"
9+
:type="type"
10+
:disabled="disabled"
11+
:readonly="readonly"
12+
:resizable="false"
13+
@update:value="(v) => emit('update:value', v)"
14+
>
15+
<template v-if="!!slots.prefix" #prefix>
16+
<slot name="prefix"></slot>
17+
</template>
18+
<template v-if="(value && clearable) || !!slots.suffix" #suffix>
19+
<div
20+
v-if="value && clearable"
21+
class="-mr-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full text-grey-800 transition-colors duration-200 hover:bg-grey-400 active:bg-grey-500"
22+
@click="emit('update:value', '')"
23+
>
24+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
25+
<path
26+
d="M6.70713 5.99999L9.35363 3.35347C9.54913 3.15847 9.54913 2.8415 9.35363 2.6465C9.15813 2.451 8.84212 2.451 8.64663 2.6465L6.00013 5.29299L3.35363 2.6465C3.15813 2.451 2.84212 2.451 2.64662 2.6465C2.45112 2.8415 2.45112 3.15847 2.64662 3.35347L5.29312 5.99999L2.64662 8.6465C2.45112 8.8415 2.45112 9.15847 2.64662 9.35347C2.74412 9.45097 2.87213 9.49999 3.00013 9.49999C3.12813 9.49999 3.25613 9.45097 3.35363 9.35347L6.00013 6.70699L8.64663 9.35347C8.74412 9.45097 8.87213 9.49999 9.00013 9.49999C9.12813 9.49999 9.25613 9.45097 9.35363 9.35347C9.54913 9.15847 9.54913 8.8415 9.35363 8.6465L6.70713 5.99999Z"
27+
fill="currentColor"
28+
/>
29+
</svg>
30+
</div>
31+
<slot name="suffix"></slot>
32+
</template>
33+
</NInput>
3634
</template>
3735

3836
<script setup lang="ts">
39-
import { computed, onMounted, ref, type StyleValue, useAttrs, useSlots } from 'vue'
37+
import { onMounted, ref, useSlots } from 'vue'
4038
import { NInput } from 'naive-ui'
4139
42-
import { cn, type ClassValue } from './utils'
43-
44-
defineOptions({
45-
inheritAttrs: false
46-
})
47-
4840
type Type = 'textarea' | 'text' | 'password'
4941
type Color = 'default' | 'white'
5042
type Size = 'medium' | 'large'
@@ -73,14 +65,7 @@ const emit = defineEmits<{
7365
'update:value': [string]
7466
}>()
7567
76-
const attrs = useAttrs()
7768
const slots = useSlots()
78-
const rootClass = computed(() => cn('w-full min-w-0 rounded-md', attrs.class as ClassValue))
79-
const rootStyle = computed(() => attrs.style as StyleValue)
80-
const inputAttrs = computed(() => {
81-
const { class: _class, style: _style, ...rest } = attrs
82-
return rest
83-
})
8469
8570
// It's weird that the prop `autofocus` of `NInput` does not work as expected, so we handle it manually.
8671
const nInput = ref<InstanceType<typeof NInput> | null>(null)
@@ -89,6 +74,29 @@ onMounted(() => {
8974
})
9075
</script>
9176

77+
<style>
78+
@layer components {
79+
/* color */
80+
.ui-text-input-color-default {
81+
--ui-text-input-bg-color: var(--ui-color-grey-300);
82+
--ui-text-input-bg-color-hover: var(--ui-color-grey-400);
83+
}
84+
85+
.ui-text-input-color-white {
86+
--ui-text-input-bg-color: var(--ui-color-grey-100);
87+
--ui-text-input-bg-color-hover: var(--ui-color-grey-100);
88+
}
89+
90+
/* size */
91+
.ui-text-input-size-medium {
92+
--ui-text-input-height: 32px;
93+
}
94+
.ui-text-input-size-large {
95+
--ui-text-input-height: 40px;
96+
}
97+
}
98+
</style>
99+
92100
<style scoped>
93101
/* it's not possible to control input's hovered-bg-color with themeOverrides, */
94102
/* so we do background color control here */
@@ -107,23 +115,4 @@ onMounted(() => {
107115
.ui-text-input :deep(.n-input__prefix) {
108116
margin-right: 8px;
109117
}
110-
111-
/* color */
112-
.color-default {
113-
--ui-text-input-bg-color: var(--ui-color-grey-300);
114-
--ui-text-input-bg-color-hover: var(--ui-color-grey-400);
115-
}
116-
117-
.color-white {
118-
--ui-text-input-bg-color: var(--ui-color-grey-100);
119-
--ui-text-input-bg-color-hover: var(--ui-color-grey-100);
120-
}
121-
122-
/* size */
123-
.ui-input-size-medium {
124-
--ui-text-input-height: 32px;
125-
}
126-
.ui-input-size-large {
127-
--ui-text-input-height: 40px;
128-
}
129118
</style>

spx-gui/src/components/ui/UITooltip.vue

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,10 @@ const attachTo = usePopupContainer()
5252
</script>
5353

5454
<style>
55-
/*
56-
Now the style is broken with scoped <style>, the `data-v-xxx` is not correctly applied on `NTooltip` element
57-
TODO: use scoped style
58-
*/
59-
.ui-tooltip {
60-
font-size: 12px; /* TODO: some text-size related var? */
61-
line-height: 1.5;
55+
@layer components {
56+
.ui-tooltip {
57+
font-size: 12px; /* TODO: some text-size related var? */
58+
line-height: 1.5;
59+
}
6260
}
6361
</style>

spx-gui/src/components/ui/form/UIFormItem.vue

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,15 +76,13 @@ const handleContentInput = debounce(() => {
7676
.ui-form-item + .ui-form-item {
7777
margin-top: 24px;
7878
}
79-
}
80-
</style>
8179
82-
<style scoped>
83-
.ui-form-item :deep(.n-form-item-feedback-wrapper) {
84-
line-height: 1.57143;
85-
}
80+
.ui-form-item :deep(.n-form-item-feedback-wrapper) {
81+
line-height: 1.57143;
82+
}
8683
87-
.ui-form-item :deep(.n-form-item-feedback-wrapper):empty {
88-
display: none;
84+
.ui-form-item :deep(.n-form-item-feedback-wrapper):empty {
85+
display: none;
86+
}
8987
}
9088
</style>

0 commit comments

Comments
 (0)