Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions web/postcss/scopeTailwindToUnapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const KEYFRAME_AT_RULES = new Set(['keyframes']);
const NON_SCOPED_AT_RULES = new Set(['font-face', 'page']);
const MERGE_WITH_SCOPE_PATTERNS: RegExp[] = [/^\.has-custom-/, /^\.dark\b/];
const UNSCOPED_PATTERNS: RegExp[] = [/^\.Theme--/];
const WEBGUI_BANNER_SELECTOR = /^(?:\.has-banner-gradient\s+)?#header\.image(?=$|[:\s>+~])/;

function shouldScopeRule(rule: Rule, targetLayers: Set<string>, includeRootRules: boolean): boolean {
const hasSelectorString = typeof rule.selector === 'string' && rule.selector.length > 0;
Expand Down Expand Up @@ -120,6 +121,11 @@ function prefixSelector(selector: string, scope: string): string {
return trimmed;
}

// The native WebGUI header contains .unapi components, rather than living inside their scope.
if (WEBGUI_BANNER_SELECTOR.test(trimmed)) {
return trimmed;
}

// Do not scope Theme-- classes - they should remain global
const firstToken = trimmed.split(/[\s>+~]/, 1)[0] ?? '';
if (!firstToken.includes('\\:') && UNSCOPED_PATTERNS.some((pattern) => pattern.test(firstToken))) {
Expand Down
53 changes: 53 additions & 0 deletions web/src/__tests__/scopeTailwindToUnapi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,59 @@ describe('scopeTailwindToUnapi plugin', () => {
expect(rule.selector).toBe('.unapi .card');
});

it('applies banner styles to the native WebGUI header outside the app scope', () => {
const plugin = scopeTailwindToUnapi();
const container = document.createElement('div');
container.innerHTML =
'<div id="header" class="image"><unraid-user-profile class="unapi"></unraid-user-profile></div>';
document.body.append(container);
document.documentElement.classList.add('has-banner-gradient');
const rule = createRule([
'#header.image:not(.unraid-consolidated-header)',
'.has-banner-gradient #header.image:not(.unraid-consolidated-header)',
'.has-banner-gradient #header.image:not(.unraid-consolidated-header)::before',
'.has-banner-gradient #header.image:not(.unraid-consolidated-header):before',
'.has-banner-gradient #header.image:not(.unraid-consolidated-header) > *',
]);

try {
plugin.Rule?.(rule);

for (const selector of rule.selectors ?? []) {
expect(document.querySelector(selector.replace(/::?before$/, ''))).not.toBeNull();
}

const header = container.querySelector('#header');
header?.classList.add('unraid-consolidated-header');
header?.replaceChildren(document.createElement('unraid-header'));
for (const selector of rule.selectors ?? []) {
expect(document.querySelector(selector.replace(/::?before$/, ''))).toBeNull();
}
header?.classList.remove('unraid-consolidated-header');
header?.replaceChildren(document.createElement('unraid-user-profile'));

document.documentElement.classList.remove('has-banner-gradient');
for (const selector of rule.selectors?.slice(1) ?? []) {
expect(document.querySelector(selector.replace(/::?before$/, ''))).toBeNull();
}
} finally {
container.remove();
document.documentElement.classList.remove('has-banner-gradient');
}
});

it('still scopes unrelated header and banner selectors', () => {
const plugin = scopeTailwindToUnapi();
const rule = createRule(['#header.image-preview', '.has-banner-gradient .card']);

plugin.Rule?.(rule);

expect(rule.selectors).toEqual([
'.unapi #header.image-preview',
'.unapi .has-banner-gradient .card',
]);
});

it('processes large rule sets within the target budget', () => {
const plugin = scopeTailwindToUnapi();
const totalRules = 10_000;
Expand Down
43 changes: 24 additions & 19 deletions web/src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
@layer theme, unapi-base, components, utilities;

/* Import theme and utilities only - no global preflight */
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
@import "@nuxt/ui";
@import 'tailwindcss/theme.css' layer(theme);
@import 'tailwindcss/utilities.css' layer(utilities);
@import '@nuxt/ui';
@import 'tw-animate-css';
@import '../../../@tailwind-shared/index.css';

Expand All @@ -25,7 +25,7 @@
@layer unapi-base {
/* Scope all Tailwind preflight styles to .unapi */
.unapi {
@import "tailwindcss/preflight.css";
@import 'tailwindcss/preflight.css';
}

/* Disable mobile-browser font-boosting (text auto-inflation) for the Vue
Expand Down Expand Up @@ -95,32 +95,37 @@ iframe#progressFrame {
}

/* Header banner compatibility tweaks */
#header.image {
#header.image:not(.unraid-consolidated-header) {
background-position: center center;
background-repeat: no-repeat;
background-size: cover;
}

.has-banner-gradient #header.image {
position: relative;
overflow: hidden;
.has-banner-gradient #header.image:not(.unraid-consolidated-header) {
--webgui-banner-color: var(
--header-gradient-end,
var(--color-header-gradient-end, var(--header-background-color))
);
isolation: isolate;
}

.has-banner-gradient #header.image::before {
.has-banner-gradient #header.image:not(.unraid-consolidated-header)::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background-repeat: no-repeat;
background-position: left center, right center;
background-size: min(30%, 320px) 100%, min(30%, 320px) 100%;
background-image:
var(--banner-gradient),
linear-gradient(270deg, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 0%, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 10%, color-mix(in srgb, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 90%, transparent) 25%, color-mix(in srgb, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 60%, transparent) 40%, color-mix(in srgb, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 30%, transparent) 55%, var(--header-gradient-start, var(--color-header-gradient-start, rgba(0, 0, 0, 0))) 70%, var(--header-gradient-start, var(--color-header-gradient-start, rgba(0, 0, 0, 0))) var(--banner-gradient-stop, 30%));
z-index: 0;
background-image: linear-gradient(
270deg,
var(--webgui-banner-color) 0,
var(--webgui-banner-color) 20rem,
color-mix(in srgb, var(--webgui-banner-color) 50%, transparent) 30rem,
transparent 40rem
);
z-index: -1;
}

.has-banner-gradient #header.image > * {
position: relative;
z-index: 1;
@media (max-width: 768px) {
.has-banner-gradient #header.image:not(.unraid-consolidated-header)::before {
background-image: linear-gradient(90deg, transparent 40%, var(--webgui-banner-color) 80%);
}
}
Loading