From a7330974a2c548c3d290eca2231b2b75ec737b90 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Thu, 24 Sep 2026 16:02:14 -0400 Subject: [PATCH] fix: various UI improvements --- frontend/src/components/PageActionsCol.vue | 9 ++ frontend/src/components/PageViewTabs.vue | 16 ++++ frontend/src/components/shared/WBtn.vue | 17 +++- frontend/src/components/shared/WToggle.vue | 58 ++++++------- frontend/src/css/_page-chrome.scss | 98 +++++++++++++++++----- frontend/src/css/tailwind.css | 17 ++-- frontend/src/layouts/MainLayout.vue | 50 ++++++++++- frontend/src/pages/AdminEditors.vue | 4 +- frontend/src/pages/AdminLocale.vue | 7 +- 9 files changed, 211 insertions(+), 65 deletions(-) diff --git a/frontend/src/components/PageActionsCol.vue b/frontend/src/components/PageActionsCol.vue index d8038a159..fdc43f858 100644 --- a/frontend/src/components/PageActionsCol.vue +++ b/frontend/src/components/PageActionsCol.vue @@ -21,6 +21,7 @@ @@ -112,6 +115,7 @@ class="h-12" v-if="showHistory" flat + square icon="la:history" :color="editorStore.isActive ? `white` : `grey`" aria-label="Page History" @@ -126,6 +130,7 @@ class="h-12" v-if="showSource" flat + square icon="la:code" :color="editorStore.isActive ? `white` : `grey`" aria-label="Page Source" @@ -140,6 +145,7 @@ @@ -201,6 +207,7 @@ class="h-12" v-if="userStore.can(`write:pages`)" flat + square icon="la:copy" :color="editorStore.isActive ? `deep-orange-2` : `grey`" aria-label="Duplicate Page" @@ -211,6 +218,7 @@ class="h-12" v-if="userStore.can(`manage:pages`)" flat + square icon="la:share" :color="editorStore.isActive ? `deep-orange-2` : `grey`" aria-label="Rename / Move Page" @@ -221,6 +229,7 @@ class="h-12" v-if="userStore.can(`delete:pages`)" flat + square icon="la:trash" :color="editorStore.isActive ? `deep-orange-2` : `grey`" aria-label="Delete Page" diff --git a/frontend/src/components/PageViewTabs.vue b/frontend/src/components/PageViewTabs.vue index 63e9bfb79..c3961b0c0 100644 --- a/frontend/src/components/PageViewTabs.vue +++ b/frontend/src/components/PageViewTabs.vue @@ -228,6 +228,22 @@ function onKeydown(ev) { border-bottom: 1px solid var(--page-chrome-rule); } + /* + The corner, through the 1px gap above. The article column's right-hand rule starts under that gap + and this line ends over it, so the one pixel where they turn the corner was left undrawn -- a + notch in what is meant to read as a single line. Only over the article column: the editor takes + its place under the strip too, and has no rule to join. + */ + &:has(+ .page-article-col)::before { + content: ''; + position: absolute; + right: 0; + bottom: -1px; + width: 1px; + height: 1px; + background-color: var(--page-chrome-rule); + } + @at-root .body--light & { background-color: $grey-2; } diff --git a/frontend/src/components/shared/WBtn.vue b/frontend/src/components/shared/WBtn.vue index e3f56e16c..805c01eba 100644 --- a/frontend/src/components/shared/WBtn.vue +++ b/frontend/src/components/shared/WBtn.vue @@ -83,6 +83,11 @@ const props = defineProps({ type: Boolean, default: false }, + /** No border radius, for a button that fills a bar edge to edge. */ + square: { + type: Boolean, + default: false + }, /** Solid background, no shadow. */ unelevated: { type: Boolean, @@ -211,7 +216,7 @@ const isSolid = computed(() => !props.flat && !props.outline) Geometry taken from Quasar's own button variables, so migrated screens keep their exact metrics: font-size 14px (text-sm) · line-height 1.715em · padding 4px 16px · dense padding .285em min-height 2.572em (2em dense) · round 3em (2.4em dense) with no padding - border-radius 3px, 28px when `rounded`, 50% when `round` + border-radius 3px, 28px when `rounded`, 50% when `round`, none when `square` Sizes are em-relative so they track the font size instead of being re-derived per variant. Padding and min-height live in `styles` below, since they are em values rather than scale steps. */ @@ -222,9 +227,13 @@ const classes = computed(() => [ ? 'rounded-full' : props.rounded ? 'rounded-[28px]' - : props.push - ? 'w-push rounded-[7px]' - : 'rounded-[3px]', + : props.square + ? props.push + ? 'w-push rounded-none' + : 'rounded-none' + : props.push + ? 'w-push rounded-[7px]' + : 'rounded-[3px]', isSolid.value && !props.unelevated ? 'shadow-card' : '', props.outline ? 'border border-current' : '', isDisabled.value ? 'pointer-events-none opacity-60' : 'cursor-pointer', diff --git a/frontend/src/components/shared/WToggle.vue b/frontend/src/components/shared/WToggle.vue index 071aa79ca..b8d897400 100644 --- a/frontend/src/components/shared/WToggle.vue +++ b/frontend/src/components/shared/WToggle.vue @@ -50,11 +50,8 @@ import { computed } from 'vue' * The track is a recessed channel and the knob sits proud of it, lit from the top left; state is * read from where the knob sits plus the status tone -- a tick when on, a cross when off. * - * WHICH part wears that tone depends on the theme, and it is the one thing about this control that - * is not just a change of value between the two. On a dark surface the pin itself is the coloured - * element, with a white mark on it, which is what gives the switch something bright to read at a - * glance. On a light one the pin stays near-white and the MARK carries the colour, because a - * saturated pin on a pale page shouts far louder than the same pin does against a dark one. + * The pin itself wears that tone, with a white mark on it, in both themes -- which is what gives the + * switch something bright to read at a glance. * * A disabled switch drops both its relief and its status colour: no glow, a neutral pin, a grey * mark, and a flat track. Dimming alone read as "slightly faded" rather than "not available", and a @@ -154,29 +151,32 @@ function toggle() { */ .w-toggle { /* - The status tone, in one place: the glow always takes it, and each theme points either the pin or - the mark at it further down. It lives on the button rather than on the three elements that draw - it, so they cannot disagree about which state they are showing. + The status tone, in one place: the glow and the pin both take it. It lives on the button rather + than on the elements that draw it, so they cannot disagree about which state they are showing. */ --w-toggle-status: var(--color-negative); --w-toggle-track: #dfe3ea; --w-toggle-rim: #ffffff; - --w-toggle-knob: #fdfdfe; + --w-toggle-knob: var(--w-toggle-status); --w-toggle-knob-rim: rgb(0 0 0 / 0.06); + /* A white ring inside the pin's edge, setting the saturated tone off from the track */ + --w-toggle-knob-border: rgb(255 255 255 / 0.5); + /* + The pin's up-left shadow is dark here too: a white one sits against the track's white rim and + gives the pin no edge on that side. Lighter than the down-right one, so the light still reads as + coming from the top left. + */ + --w-toggle-knob-highlight: rgb(0 0 0 / 0.06); --w-toggle-shadow: rgb(0 0 0 / 0.18); --w-toggle-highlight: rgb(255 255 255 / 0.95); --w-toggle-cast: rgb(0 0 0 / 0.12); --w-toggle-glow: 0.62; - --w-toggle-mark: var(--w-toggle-status); + --w-toggle-mark: #ffffff; --w-toggle-mark-disabled: #8a8f98; - /* - A pale pin needs no relief under the mark, so the copy is the pin's own tone and draws nothing. - Keeping it painted rather than hidden is what makes the dark theme a change of colour and not a - change of markup. - */ - --w-toggle-mark-shadow: #ffffff; - /* The pin is neutral here either way, so disabled changes only the mark */ - --w-toggle-knob-disabled: var(--w-toggle-knob); + /* White on the positive tone is a weak pairing, so the mark gets an edge to read against */ + --w-toggle-mark-shadow: rgb(0 0 0 / 0.3); + /* Shed the status colour when the control cannot be moved */ + --w-toggle-knob-disabled: #fdfdfe; } .w-toggle[aria-checked='true'] { @@ -186,18 +186,16 @@ function toggle() { :global(body.body--dark .w-toggle) { --w-toggle-track: #262c38; --w-toggle-rim: #39414f; - --w-toggle-knob: var(--w-toggle-status); --w-toggle-knob-rim: rgb(255 255 255 / 0.1); + /* Softer than the light theme's, which stands out more against the dark track */ + --w-toggle-knob-border: rgb(255 255 255 / 0.25); + --w-toggle-knob-highlight: var(--w-toggle-highlight); --w-toggle-shadow: rgb(0 0 0 / 0.6); --w-toggle-highlight: rgb(255 255 255 / 0.07); --w-toggle-cast: rgb(0 0 0 / 0.45); /* Held up a little: the same tone has less to carry against a dark channel than a pale one */ --w-toggle-glow: 0.72; - --w-toggle-mark: #ffffff; --w-toggle-mark-disabled: #aeb4bf; - /* White on the positive tone is a weak pairing, so the mark gets an edge to read against */ - --w-toggle-mark-shadow: rgb(0 0 0 / 0.3); - /* Shed the status colour when the control cannot be moved; this is the pin's old neutral */ --w-toggle-knob-disabled: #6b7382; } @@ -238,16 +236,17 @@ function toggle() { } /* - The transition is declared here rather than with Tailwind's `transition-transform`, because in the - dark theme the pin's colour has to travel with its movement. Same duration and easing as the glow, + The transition is declared here rather than with Tailwind's `transition-transform`, because the + pin's colour has to travel with its movement. Same duration and easing as the glow, which shares the knob's offset class and so must move in step with it. */ .w-toggle__knob { background-color: var(--w-toggle-knob); box-shadow: + inset 0 0 0 2px var(--w-toggle-knob-border), 0 0 0 1px var(--w-toggle-knob-rim), 2px 2px 4px var(--w-toggle-shadow), - -2px -2px 4px var(--w-toggle-highlight); + -2px -2px 4px var(--w-toggle-knob-highlight); transition: translate 0.2s var(--ease-standard), background-color 0.2s var(--ease-standard); @@ -272,8 +271,7 @@ function toggle() { The mark's drop shadow. Both copies are positioned, so painting order is document order and the real mark covers this one -- `z-index: -1` would put it behind the knob's own background instead. - What it draws is the theme's business, and both answers are in the variable blocks above: a pixel - of dark under a white mark on a coloured pin, or nothing at all on a pale one. + A pixel of dark under the white mark, so it keeps an edge against the coloured pin. */ .w-toggle__mark-shadow { position: absolute; @@ -295,7 +293,9 @@ function toggle() { .w-toggle--disabled .w-toggle__knob { background-color: var(--w-toggle-knob-disabled); - box-shadow: 0 0 0 1px var(--w-toggle-knob-rim); + box-shadow: + inset 0 0 0 2px var(--w-toggle-knob-border), + 0 0 0 1px var(--w-toggle-knob-rim); } .w-toggle--disabled .w-toggle__track { diff --git a/frontend/src/css/_page-chrome.scss b/frontend/src/css/_page-chrome.scss index 7eaad6d24..c852f6930 100644 --- a/frontend/src/css/_page-chrome.scss +++ b/frontend/src/css/_page-chrome.scss @@ -27,6 +27,40 @@ @use 'palette' as *; @use 'theme' as *; +/* + One edge of a box drawn as a hairline -- one DEVICE pixel, like `.w-hairline` -- in the colour held + by the custom property `$color-var`. `$pseudo` is which of the box's two pseudo-elements draws it, + so a box can have a hairline on both edges. + + The border is kept, transparent, so the box is exactly the size it was with a real one, and the line + is painted over that border area by a pseudo-element scaled by `1 / --w-dpr`. The scale runs towards + the box's OUTER edge, so two of these meeting at a seam -- a bar's underline and the top edge of the + bar below it -- sit against each other with nothing between them. What the scale leaves bare shows + the box's own background, which is why the gradients using this are `border-box`: under the default + `padding-box` origin a gradient repeats into the border area, and its first stop shows as a sliver. + + Screen only: the print stylesheet redraws these edges as borders of its own. +*/ +@mixin hairline-edge($side, $color-var, $pseudo: after) { + position: relative; + border-#{$side}: 1px solid transparent; + + @media screen { + &::#{$pseudo} { + content: ''; + position: absolute; + left: 0; + right: 0; + #{$side}: -1px; + height: 1px; + background-color: var(#{$color-var}); + transform: scaleY(calc(1 / var(--w-dpr, 1))); + transform-origin: #{$side} left; + pointer-events: none; + } + } +} + /* Where the contents column stops being able to afford 300px. A page view's own threshold, not one of the app's -- `_palette.scss` is for the breakpoints the whole app shares, and this one is a function of a @@ -80,10 +114,17 @@ $toc-overlay-max: 749.98px; opacity: 0.12; } +/* + The pair of lines between this bar and the page header under it -- this bar's underline, then the + header's top edge -- are hairlines drawn by `hairline-edge` rather than borders: a 1px border is 1.5 + or 2 device pixels on a scaled display, twice the weight of every `.w-hairline` rule around it. +*/ .page-breadcrumbs { + @include hairline-edge(bottom, --page-breadcrumbs-rule); + @at-root .body--light & { - background: linear-gradient(to bottom, $grey-1 0%, $grey-3 100%); - border-bottom: 1px solid $grey-4; + background: linear-gradient(to bottom, $grey-1 0%, $grey-3 100%) border-box; + --page-breadcrumbs-rule: #{$grey-4}; } /* The bar sets a background per theme, so it owes a foreground too: the LAST crumb -- the current @@ -94,8 +135,8 @@ $toc-overlay-max: 749.98px; color: var(--color-black); } @at-root .body--dark & { - background: linear-gradient(to bottom, $dark-3 0%, $dark-4 100%); - border-bottom: 1px solid $dark-3; + background: linear-gradient(to bottom, $dark-3 0%, $dark-4 100%) border-box; + --page-breadcrumbs-rule: #{$dark-3}; color: var(--color-white); } @@ -134,15 +175,17 @@ $toc-overlay-max: 749.98px; */ .page-breadcrumbs--version { @at-root .body--light & { - background: linear-gradient(to bottom, var(--color-indigo-6) 0%, var(--color-indigo-7) 100%); - border-bottom: 1px solid var(--color-indigo-9); + background: linear-gradient(to bottom, var(--color-indigo-6) 0%, var(--color-indigo-7) 100%) + border-box; + --page-breadcrumbs-rule: var(--color-indigo-9); color: #fff; } /* -> A step down the same ramp, so the band reads as deliberate against a dark page rather than as the light theme's bar left switched on */ @at-root .body--dark & { - background: linear-gradient(to bottom, var(--color-indigo-7) 0%, var(--color-indigo-9) 100%); - border-bottom: 1px solid var(--color-indigo-9); + background: linear-gradient(to bottom, var(--color-indigo-7) 0%, var(--color-indigo-9) 100%) + border-box; + --page-breadcrumbs-rule: var(--color-indigo-9); color: #fff; } } @@ -185,6 +228,8 @@ $toc-overlay-max: 749.98px; .page-header { height: 95px; + @include hairline-edge(top, --page-header-rule); + @include hairline-edge(bottom, --page-header-underline, before); /* Sized by its contents on a phone instead, which comes out around 70px: the 95px is pitched for a 64px @@ -200,14 +245,15 @@ $toc-overlay-max: 749.98px; } @at-root .body--light & { - background: linear-gradient(to bottom, $grey-2 0%, $grey-1 100%); - border-bottom: 1px solid $grey-4; - border-top: 1px solid #fff; + background: linear-gradient(to bottom, $grey-2 0%, $grey-1 100%) border-box; + --page-header-rule: #fff; + --page-header-underline: #{$grey-4}; } @at-root .body--dark & { - background: linear-gradient(to bottom, $dark-4 0%, $dark-3 100%); - // border-bottom: 1px solid $dark-5; - border-top: 1px solid $dark-6; + background: linear-gradient(to bottom, $dark-4 0%, $dark-3 100%) border-box; + // -> No underline in the dark theme, and no border holding a place for one either + border-bottom: 0; + --page-header-rule: #{$dark-6}; } .no-height .q-field__control { @@ -396,14 +442,26 @@ $toc-overlay-max: 749.98px; background-color: $dark-5; } - // A light rule on the light sidebar, near-black on the dark one -- it reads as the bevel between - // two panels rather than as a drawn line. - // - // The original set a background-colour here as well as a border. It never showed: the element is - // 1px tall with `box-sizing: border-box`, so the content box is 0px and the opaque border covers - // it completely. Only the border colour is carried across. + /* + A bevel between two panels rather than a drawn line: a grey hairline over a light one on the light + sidebar, over a near-black one on the dark. The grey is `--page-chrome-rule`, the article column's + right-hand edge beside it -- `$grey-4` light, the lighter dark grey dark -- and the second line is + this separator's own `--w-hairline-color`. + + One separator drawn two lines tall rather than two separators, so the hairline transform -- which + scales from the top -- thins both halves alike and they stay touching. + */ .w-separator { --w-hairline-color: #fff; + height: 2px; + + &::after { + background: linear-gradient( + to bottom, + var(--page-chrome-rule) 50%, + var(--w-hairline-color) 50% + ); + } } @at-root .body--dark & .w-separator { --w-hairline-color: #070a0d; diff --git a/frontend/src/css/tailwind.css b/frontend/src/css/tailwind.css index 2d46686c8..224441a18 100644 --- a/frontend/src/css/tailwind.css +++ b/frontend/src/css/tailwind.css @@ -767,11 +767,18 @@ ); } - /* The wider, fainter band trailing below the heading */ + /* + The wider, fainter band trailing below the heading. + + Under the heading's own box come three hairlines -- a gap, the line in `::after` below, a gap -- + and then this band. All three are one DEVICE pixel, so the band starts three of them down rather + than 3px: a whole CSS pixel of gap either side of a one-device-pixel line reads as the line + floating in a white stripe on a scaled display. + */ .w-section-header::before { content: ''; position: absolute; - bottom: -13px; + top: calc(100% + 3px / var(--w-dpr, 1)); left: 0; z-index: 0; width: 100%; @@ -784,7 +791,7 @@ } /* - The hairline immediately under it, fading out towards the right. + The hairline under it, one device pixel clear of it, fading out towards the right. Scaled by `1 / --w-dpr` like `.w-hairline`, so it paints exactly one DEVICE pixel: a 1px CSS rule is 1.5 device pixels at 150% display scaling, which reads visibly heavier than the same @@ -793,7 +800,7 @@ .w-section-header::after { content: ''; position: absolute; - bottom: -2px; + top: calc(100% + 1px / var(--w-dpr, 1)); left: 0; z-index: 0; width: 100%; @@ -804,7 +811,7 @@ color-mix(in srgb, var(--color-primary) 25%, transparent) ); transform: scaleY(calc(1 / var(--w-dpr, 1))); - transform-origin: bottom; + transform-origin: top; } body.body--dark .w-section-header { diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index 6a0975959..a6d223196 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -58,19 +58,21 @@ class="flex-1 px-2" flat dense + square icon="la:globe" :label="siteStore.localeAlias(commonStore.locale)" :aria-label="siteStore.localeAlias(commonStore.locale)" size="sm"> - +