feat: mobile / table UI optimizations

scarlett
NGPixel 4 weeks ago
parent 57cda5162c
commit 527b8e9f64
No known key found for this signature in database

@ -158,6 +158,22 @@ Content of the second tab.
background-color: var(--tabs-panel-bg);
}
/*
Tighter on a phone. The panel sits inside an article that pads by 8px there, so 20px of its own
put the text 28px in from the edge of the screen -- a third of the indent a 390px column can
afford, spent twice over on the same margin.
599.98px is the app's own phone breakpoint -- --breakpoint-sm is 600px in css/tailwind.css. A
block cannot read the app's Sass variables, so the value is written out, as block-infobox does
with its own. (No backticks in here: this whole stylesheet is a template literal, and one ends
it mid-rule.)
*/
@media (max-width: 599.98px) {
.panel {
padding: 12px;
}
}
/* -> The panel owns the spacing, so the content inside it does not add its own at the edges */
::slotted(block-tab) {
margin-bottom: 0;

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts.
263 icons.
264 icons.
*/
export const BUNDLED_ICONS = {
"la:angle-double-right": {"body":"<path fill=\"currentColor\" d=\"M9.094 4.781L7.688 6.22l9.78 9.78l-9.78 9.781l1.406 1.438L20.313 16zm7 0L14.687 6.22L24.47 16l-9.782 9.781l1.407 1.438L27.312 16z\"/>","width":32,"height":32},
@ -189,6 +189,7 @@ export const BUNDLED_ICONS = {
"mdi:eye-off-outline": {"body":"<path fill=\"currentColor\" d=\"M2 5.27L3.28 4L20 20.72L18.73 22l-3.08-3.08c-1.15.38-2.37.58-3.65.58c-5 0-9.27-3.11-11-7.5c.69-1.76 1.79-3.31 3.19-4.54zM12 9a3 3 0 0 1 3 3a3 3 0 0 1-.17 1L11 9.17A3 3 0 0 1 12 9m0-4.5c5 0 9.27 3.11 11 7.5a11.8 11.8 0 0 1-4 5.19l-1.42-1.43A9.86 9.86 0 0 0 20.82 12A9.82 9.82 0 0 0 12 6.5c-1.09 0-2.16.18-3.16.5L7.3 5.47c1.44-.62 3.03-.97 4.7-.97M3.18 12A9.82 9.82 0 0 0 12 17.5c.69 0 1.37-.07 2-.21L11.72 15A3.064 3.064 0 0 1 9 12.28L5.6 8.87c-.99.85-1.82 1.91-2.42 3.13\"/>","width":24,"height":24},
"mdi:file-document-outline": {"body":"<path fill=\"currentColor\" d=\"M6 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm0 2h7v5h5v11H6zm2 8v2h8v-2zm0 4v2h5v-2z\"/>","width":24,"height":24},
"mdi:file-search-outline": {"body":"<path fill=\"currentColor\" d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h7c-.41-.25-.8-.56-1.14-.9c-.33-.33-.61-.7-.86-1.1H6V4h7v5h5v1.18c.71.16 1.39.43 2 .82V8zm6.31 16.9c1.33-2.11.69-4.9-1.4-6.22c-2.11-1.33-4.91-.68-6.22 1.4c-1.34 2.11-.69 4.89 1.4 6.22c1.46.93 3.32.93 4.79.02L22 23.39L23.39 22zm-3.81.1a2.5 2.5 0 0 1-2.5-2.5a2.5 2.5 0 0 1 2.5-2.5a2.5 2.5 0 0 1 2.5 2.5a2.5 2.5 0 0 1-2.5 2.5\"/>","width":24,"height":24},
"mdi:file-tree": {"body":"<path fill=\"currentColor\" d=\"M3 3h6v4H3zm12 7h6v4h-6zm0 7h6v4h-6zm-2-4H7v5h6v2H5V9h2v2h6z\"/>","width":24,"height":24},
"mdi:flag-outline": {"body":"<path fill=\"currentColor\" d=\"m12.36 6l.4 2H18v6h-3.36l-.4-2H7V6zM14 4H5v17h2v-7h5.6l.4 2h7V6h-5.6\"/>","width":24,"height":24},
"mdi:food-apple-outline": {"body":"<path fill=\"currentColor\" d=\"M20 10a5.27 5.27 0 0 0-7-2V3h-2v5a5.27 5.27 0 0 0-7 2c-2 3 3 12 5 12s2-1 3-1s1 1 3 1s7-9 5-12m-1.75 3.38c-.62 2.47-1.84 4.74-3.55 6.62c-.2 0-.43-.1-.6-.25a3.34 3.34 0 0 0-4.2 0c-.17.15-.4.25-.6.25a15.3 15.3 0 0 1-3.55-6.61c-.25-.73-.3-1.52-.09-2.27A3.37 3.37 0 0 1 8.5 9.4c.56.01 1.11.14 1.61.39l.89.45h2l.89-.45c.5-.25 1.05-.38 1.61-.39c1.18.03 2.26.68 2.84 1.71c.21.75.16 1.54-.09 2.27M11 5C5.38 8.07 4.11 3.78 4.11 3.78S6.77.19 11 5\"/>","width":24,"height":24},
"mdi:format-align-center": {"body":"<path fill=\"currentColor\" d=\"M3 3h18v2H3zm4 4h10v2H7zm-4 4h18v2H3zm4 4h10v2H7zm-4 4h18v2H3z\"/>","width":24,"height":24},

@ -312,6 +312,7 @@ import { useI18n } from 'vue-i18n'
import { bindCollabEditor, startCollabSession, stopCollabSession } from '@/composables/collab'
import { dialog } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { useMinWidth } from '@/composables/screen'
import { assetPath } from '@/helpers/assets'
import { blockMarkdown } from '@/helpers/blocks'
import { findEditableTables } from '@/helpers/markdownTable'
@ -418,8 +419,21 @@ const HEADER_ICONS = [
*/
const SYNC_SCROLL = { behavior: 'smooth', block: 'start', inline: 'nearest' }
/**
* Whether the window is wide enough to open the preview beside the source.
*
* 1024 is the app's `md` breakpoint (`css/tailwind.css`). Below it the two panes are half a small window
* each, and the source is the one being typed into so the preview starts closed and is opened when
* wanted, from the toolbar button that takes its place.
*/
const isAtLeastMd = useMinWidth(1024)
const state = reactive({
previewShown: true,
/*
Read once, as a DEFAULT rather than a binding: past this first value the pane is the author's to open
and close, and a bound one would slam it shut the moment a window was dragged narrower mid-edit.
*/
previewShown: isAtLeastMd.value,
previewScrollSync: true
})

@ -1,11 +1,17 @@
<template>
<w-layout class="fileman" view="hHh lpR lFr" container>
<!--
Three toolbars in one flex row, which below ~700px is more than fits: the row overflowed and took the
last of them -- the one holding Close -- off the side of the screen, so on a phone the overlay could
be opened and not shut. They wrap onto two lines instead below 900px; see the stylesheet, which is
also why each of the three carries a name.
-->
<w-header class="card-header">
<w-toolbar dark>
<w-toolbar class="fileman-hdr-title" dark>
<w-icon name="img:/_assets/icons/fluent-folder.svg" left size="md" />
<span>{{ t(`fileman.title`) }}</span>
</w-toolbar>
<w-toolbar dark>
<w-toolbar class="fileman-hdr-search" dark>
<!-- -> Same gate the sidebar's locale button uses in `MainLayout`: with the site's locale menu
off, switching locale is not something a reader is offered anywhere -->
<w-btn
@ -57,7 +63,7 @@
-> No right margin on the last control: the toolbar's own 12px is already close to the 9-10px the
header leaves above and below.
-->
<w-toolbar dark>
<w-toolbar class="fileman-hdr-actions" dark>
<w-space />
<w-btn
class="mr-2"
@ -83,7 +89,16 @@
</w-btn-group>
</w-toolbar>
</w-header>
<w-drawer class="fileman-left" :model-value="true" :width="350">
<!--
The folder tree. Beside the list where there is room for both, and a panel over it where there is
not -- which is what `WDrawer` does on its own below 1024px, except that this was bound `:model-value
="true"`: one-way, and permanently open. Overlaying, that put 350px of tree across a 390px screen
with no way to put it away, since the drawer asks to be closed when its scrim is tapped and nothing
was listening. `treeDrawerOpen` is that listener, and above the breakpoint it answers true always.
Narrower while it overlays, so there is a comfortable width of scrim left to tap on.
-->
<w-drawer class="fileman-left" v-model="treeDrawerOpen" :width="isTreeOverlay ? 300 : 350">
<w-scroll-area :thumb-style="thumbStyle" :bar-style="barStyle" style="height: 100%">
<!--
-> No side padding: the tree's rows run the full width of the drawer, so a hovered or
@ -103,7 +118,7 @@
</div>
</w-scroll-area>
</w-drawer>
<w-drawer class="fileman-right" :model-value="screen.gt.md" :width="350" side="right">
<w-drawer class="fileman-right" :model-value="detailsPaneShown" :width="350" side="right">
<w-scroll-area :thumb-style="thumbStyle" :bar-style="barStyle" style="height: 100%">
<div class="p-4">
<template v-if="currentFileDetails">
@ -136,7 +151,18 @@
</w-scroll-area>
</w-drawer>
<w-page-container>
<w-page class="fileman-center column">
<!--
Tapping this pane puts the tree panel away, which is the "tap outside to dismiss" the drawer's own
scrim would normally provide. It cannot here: `WDrawer` teleports that scrim to <body> at z-30, and
this whole view is inside a dialog which paints above it -- so the scrim is invisible, and a tap
beside the panel lands on this pane instead. Rather than raise the z-index of a scrim shared with
the site's nav drawer, the pane takes the tap it is already receiving.
On the pane rather than on the list inside it, because the list is only as tall as its rows: below
the last file the tap reaches this element and nothing else. The handler steps aside for the
toolbar, which holds the button that OPENS the tree.
-->
<w-page class="fileman-center column" @click="dismissTreeOverlay">
<!-- TOOLBAR ----------------------------------------------------- -->
<w-toolbar class="fileman-toolbar">
<template v-if="state.isUploading">
@ -157,6 +183,25 @@
v-if="state.uploadPercentage < 100" />
</template>
<template v-else>
<!--
What opens the tree while it is a panel: nothing else does, and the tree is how a reader
gets to another folder. First in the toolbar rather than in the pushed group, because it is
about where they are rather than about what to do here.
-->
<w-btn
v-if="isTreeOverlay"
class="mr-2"
flat
dense
no-caps
color="grey"
:aria-label="t(`common.sidebar.browse`)"
icon="mdi:file-tree"
@click="state.treeOpen = true">
<w-tooltip anchor="bottom middle" self="top middle">{{
t(`common.sidebar.browse`)
}}</w-tooltip>
</w-btn>
<w-space />
<w-btn
class="mr-2"
@ -276,6 +321,23 @@
:aria-label="t(`common.actions.upload`)"
icon="la:cloud-upload-alt"
@click="uploadFile" />
<!--
Insert lives in the details pane, which is a 350px column with no overlay form -- so below
1440px the editor's insert flow could be opened and never completed: the file list offers it
only through a right-click menu, which is not a gesture a touch screen has. Here it is the
same call on the same selection, in the one place that is always on screen.
-->
<w-btn
v-if="insertMode && !detailsPaneShown && state.currentFileId"
class="ml-2"
flat
dense
no-caps
color="primary"
:label="t(`common.actions.insert`)"
:aria-label="t(`common.actions.insert`)"
icon="la:plus-circle"
@click="insertItem()" />
</template>
</w-toolbar>
<div class="flex flex-wrap" style="flex: 1 1 100%">
@ -451,7 +513,7 @@ import { useRouter } from 'vue-router'
import { dialog } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { useScreen } from '@/composables/screen'
import { useMinWidth, useScreen } from '@/composables/screen'
import { useDark } from '@/composables/dark'
import { useCommonStore } from '@/stores/common'
@ -533,6 +595,12 @@ const state = reactive({
searchIsFocused: false,
currentFolderId: null,
currentFileId: null,
/**
* Whether the folder tree has been opened. Only consulted while it overlays the list beside it, it
* is simply there. Deliberately NOT one of the remembered view options: those describe how a list is
* drawn, and this is a panel that is open at the moment.
*/
treeOpen: false,
treeNodes: {},
treeRoots: [],
displayMode: 'title',
@ -590,6 +658,34 @@ const treeComp = ref(null)
const insertMode = computed(() => siteStore.overlayOpts?.insertMode ?? false)
/**
* Whether the folder tree is a panel over the list rather than a column beside it.
*
* 1024 is `WDrawer`'s own default `overlayBelow`, which is what actually decides how the drawer draws
* itself this is the same question asked from the outside, so that the toolbar knows whether to offer a
* way in. The two have to agree.
*/
const isAtLeastMd = useMinWidth(1024)
const isTreeOverlay = computed(() => !isAtLeastMd.value)
/**
* Whether the details pane is beside the list. It is a 350px column with no overlay form, so below 1440px
* there is simply no room for it -- which is also why the Insert button it holds needs a second home; see
* the toolbar.
*/
const detailsPaneShown = computed(() => screen.gt.md)
/**
* The tree drawer's open state: always open where it has a column of its own, and the reader's to decide
* where it overlays. The setter is what the drawer's scrim reaches when it is tapped.
*/
const treeDrawerOpen = computed({
get: () => !isTreeOverlay.value || state.treeOpen,
set: (val) => {
state.treeOpen = val
}
})
const folderPath = computed(() => {
if (!state.currentFolderId) {
return '/'
@ -716,12 +812,35 @@ const currentFileDetails = computed(() => {
watch(
() => state.currentFolderId,
async (newValue) => {
/*
Picking a folder is what the tree is open FOR, so it closes behind the choice -- the contents of that
folder are in the list underneath, which the panel is covering. Only while it overlays; beside the
list it is a column and there is nothing to close.
*/
state.treeOpen = false
await loadTree({ parentId: newValue })
}
)
// METHODS
/**
* Put the folder tree away when the list behind it is tapped.
*
* A no-op unless the tree is actually overlaying and open, so an ordinary click on a file which is what
* this handler mostly receives costs nothing and behaves as it always did.
*/
function dismissTreeOverlay(ev) {
if (!isTreeOverlay.value || !state.treeOpen) {
return
}
// -> The toolbar's own button is what opened it; closing here would undo that on the way back up
if (ev?.target?.closest?.('.fileman-toolbar')) {
return
}
state.treeOpen = false
}
function close() {
siteStore.overlay = null
}
@ -1375,7 +1494,51 @@ onBeforeUnmount(() => {
</script>
<style lang="scss">
/*
Where the overlay's header stops fitting on one line. Its own threshold: the three toolbars want roughly
700px between them, and this leaves a margin over that. Not one of the app's shared breakpoints, though
it is the same 900 the site header collapses its actions at -- both are simply where a window stops
having room for a row of chrome.
*/
$fileman-hdr-wrap-max: 899.98px;
.fileman {
/*
THE HEADER ON A NARROW SCREEN
=============================
`.card-header` is a flex row, and its three toolbars are each `w-full`, so with wrapping turned on they
would take a line each -- three lines of chrome above a file list. Two is enough:
line 1 the title, with the help and Close group pushed to its end
line 2 the locale button and the search field
The title and the actions give up `w-full` to share the first line; the search toolbar keeps it and is
ordered last, which is what puts it on the second. Close is what this is for -- off the end of the row
it was unreachable, and it is the only way out of the overlay.
*/
@media (max-width: $fileman-hdr-wrap-max) {
> .card-header {
flex-wrap: wrap;
}
&-hdr-title {
width: auto;
flex: 1 1 auto;
/* -> "File Manager" wrapped to two lines rather than letting the row grow */
white-space: nowrap;
}
&-hdr-actions {
width: auto;
flex: 0 0 auto;
}
&-hdr-search {
order: 1;
}
}
/*
The locale button is cut to the same 7px as the search field and Close, where `WBtn`'s flat variant
is 3px. Unlayered, because an SFC style block is not a Tailwind layer -- which is what lets it beat

@ -124,8 +124,8 @@ import PageNewMenu from '@/components/PageNewMenu.vue'
* The phone header's overflow menu: one button standing in for every action the bar shows as its own
* icon on a wide screen -- New Page, File Manager, Inbox, Administration, and the account.
*
* Rendered only below the `sm` breakpoint; see `HeaderNav`. The rows repeat the permission tests the
* buttons they replace make, rather than being handed a list, so the two cannot drift apart.
* Rendered only below 900px; see `HeaderNav`. The rows repeat the permission tests the buttons they
* replace make, rather than being handed a list, so the two cannot drift apart.
*/
// STORES

@ -14,31 +14,33 @@
</w-toolbar>
<!-- -> Inline between the title and the actions only where there is room for all three; on a
phone the field gets a row of its own at the bottom of this header instead -->
<header-search v-if="!isPhoneViewport" />
<header-search v-if="!isSearchCollapsed" />
<w-toolbar style="height: 64px">
<w-space />
<transition name="syncing">
<w-spinner v-show="commonStore.routerLoading" size="20px" class="text-accent" />
</transition>
<!--
The phone form of everything to the right of the spinner: the search field's opener, and one
button for the rest. Five icon buttons whose meaning is only in a tooltip is not something a
touch screen can offer, and they do not fit beside the site title in any case.
The two halves of the right-hand group collapse at different widths, so they are separate tests
rather than one phone/desktop switch: the field is the first thing that stops fitting beside the
site title, and the five buttons hold out for another 300px.
-->
<template v-if="isPhoneViewport">
<w-btn
v-if="siteStore.features.search"
class="ml-4"
flat
round
dense
:icon="searchRowIsOpen ? `la:times` : `la:search`"
color="white"
:aria-label="searchRowIsOpen ? t(`common.actions.close`) : t(`common.header.search`)"
:aria-expanded="searchRowIsOpen"
@click="toggleSearchRow" />
<header-actions-menu />
</template>
<w-btn
v-if="isSearchCollapsed && siteStore.features.search"
class="ml-4"
flat
round
dense
:icon="searchRowIsOpen ? `la:times` : `la:search`"
color="white"
:aria-label="searchRowIsOpen ? t(`common.actions.close`) : t(`common.header.search`)"
:aria-expanded="searchRowIsOpen"
@click="toggleSearchRow" />
<!--
One button for the five. Icon buttons whose meaning is only in a tooltip are not something a
touch screen can offer at all, and by 900px they are also crowding the site title.
-->
<header-actions-menu v-if="isActionsCollapsed" />
<template v-else>
<w-btn
v-if="userStore.can(`write:pages`)"
@ -122,7 +124,7 @@
`backdrop-filter` blur into the middle of the animation -- which is what made it stutter.
-->
<transition name="header-search-row" @after-enter="searchRow?.focus()">
<div v-if="isPhoneViewport && searchRowIsOpen" class="header-search-row">
<div v-if="isSearchCollapsed && searchRowIsOpen" class="header-search-row">
<header-search ref="searchRow" row />
</div>
</transition>
@ -179,11 +181,22 @@ const searchRowIsOpen = ref(false)
// COMPUTED
/**
* Below the `sm` breakpoint (`css/tailwind.css`), which is this app's phone boundary where the
* search field and the five action buttons stop fitting alongside the site title.
* Below the `sm` breakpoint (`css/tailwind.css`), where the search field gives up its place between the
* site title and the actions and becomes a button that opens a row of its own.
*/
const isAtLeastSm = useMinWidth(600)
const isPhoneViewport = computed(() => !isAtLeastSm.value)
const isSearchCollapsed = computed(() => !isAtLeastSm.value)
/**
* Below 900px, where the five action buttons become the one overflow menu.
*
* A separate question from the search field above, and a wider one: the field is what stops fitting
* first, while the buttons are 5 × 40px that only start crowding the title around here. The same 900 the
* profile and search cards collapse their sidebars at, which is coincidence rather than a shared cause
* it is simply where a window stops being a desktop one.
*/
const isAtLeast900 = useMinWidth(900)
const isActionsCollapsed = computed(() => !isAtLeast900.value)
// WATCHERS
@ -211,12 +224,12 @@ onBeforeUnmount(() => {
// METHODS
/*
Ctrl+K at phone width, where the field is not mounted and so cannot claim the shortcut itself: this
Ctrl+K below 600px, where the field is not mounted and so cannot claim the shortcut itself: this
opens the row, and `HeaderSearch` focuses on mount. Above the breakpoint, and while the row is
already down, the field's own handler is the one that answers -- see `HeaderSearch.handleKeyPress`.
*/
function onKeydown(ev) {
if (!isPhoneViewport.value || searchRowIsOpen.value || !siteStore.features.search) {
if (!isSearchCollapsed.value || searchRowIsOpen.value || !siteStore.features.search) {
return
}
if (ev.ctrlKey && ev.key === 'k' && !siteStore.overlayIsShown) {

@ -164,6 +164,13 @@ watch(
</script>
<style lang="scss">
/*
Just under the width `MainLayout` gives this sidebar's drawer as `overlayBelow` (1100), which is where it
stops being a column beside the content and starts overlaying it. Not one of the app's shared breakpoints
-- it belongs to this sidebar -- so it is stated here and cross-referenced there.
*/
$sidebar-overlay-max: 1099.98px;
.sidebar-nav {
border-top: 1px solid rgba(255, 255, 255, 0.15);
/* -> Fills whatever the drawer's flex column has left over, rather than subtracting the action bar
@ -345,9 +352,10 @@ watch(
`content: none` rather than `display: none`, so the box is never generated. Stated after both
`--flipped` rules and at their specificity, so it takes the notch away whichever edge it was cut
from -- and 1023.98px is `WDrawer`'s own 1024px overlay breakpoint, which the two have to agree on.
from -- and `$sidebar-overlay-max` is the width MainLayout hands the drawer as `overlayBelow`, which
the two have to agree on.
*/
@media (max-width: $breakpoint-sm-max) {
@media (max-width: $sidebar-overlay-max) {
.w-list .w-item.router-link-exact-active::after {
content: none;
}

@ -398,9 +398,36 @@ function removePendingAsset(item) {
</script>
<style lang="scss">
/*
Just under the width at which the site's nav sidebar stops taking a column of its own -- the number
`MainLayout` hands its drawer as `overlayBelow`, and the same one `NavSidebar` states for its own use.
Below it the corner button lands in this rail; see the padding rule.
*/
$sidebar-overlay-max: 1099.98px;
/** One row of this rail, which is what the bottom group has to clear. Matches the buttons' `h-12`. */
$action-btn-height: 3rem;
.page-actions {
flex: 0 0 56px;
/*
Room at the foot of the rail for the button in the corner of the window -- scroll-to-top, or the
contents panel's opener below 750px (`MainLayout` and `pages/Index.vue` respectively). While the nav
sidebar has a column of its own that button is a disc straddling the sidebar's inner edge, nowhere
near this rail; once the sidebar overlays instead, the button is flush in the bottom-right corner,
which is exactly where this rail ends -- and it was landing on top of Delete Page. A tap at the middle
of that button's box reached the corner button instead, so the last action in the rail was the one
action a reader could not take.
Padding on the rail rather than a margin on the last button: what is last here depends on the reader's
permissions and on whether the editor is open, and the space is owed to whichever of them it turns out
to be. The rail scrolls its own overflow, so this is inside what scrolls and cannot be scrolled behind.
*/
@media (max-width: $sidebar-overlay-max) {
padding-bottom: $action-btn-height;
}
/*
Gone on a phone while a page is being read: the rail is a column of icon buttons whose labels only
ever appear in a tooltip, which a touch screen has no way to show -- so it reads as six unexplained

@ -37,7 +37,7 @@
and left at the top it sat above the middle of the icon beside it. A page that has one is taller
than everything else in the row, so there is nothing to centre and this changes nothing.
-->
<div class="min-w-0 flex-1 flex flex-col justify-center p-4">
<div class="min-w-0 flex-1 flex flex-col justify-center p-2 sm:p-4">
<div class="text-h4 page-header-title">
<span
v-if="isEditing"
@ -849,8 +849,13 @@ function notImplemented() {
The title comes down from `text-h4`, which is a 34px display size written for a header the width of a
desktop window: at 390px a title of any length wrapped, and the description under it was pushed out of
the 95px bar. 24px is the same step `text-h5` takes, chosen as a value rather than as that class so the
size lives beside the breakpoint that asks for it.
the bar. 24px is the same step `text-h5` takes, chosen as a value rather than as that class so the size
lives beside the breakpoint that asks for it.
The text column's padding halves with it, which is most of what brings the bar's own height down --
`pages/Index.vue` takes the fixed 95px off on the same breakpoint, so what is left of it is this
column. Horizontal too, and deliberately: at 8px the title lines up with the article underneath, which
now pads by the same amount.
And the actions go, all of them -- Watch, Print, the review queue, Edit -- because they are icons
squeezed against the right edge of a row that has no room for the title as it is. Nothing is lost that

@ -280,7 +280,7 @@ onBeforeUnmount(() => {
font-size: inherit;
font-weight: inherit;
text-decoration: none;
/* Long headings wrap rather than being cut off; the sidebar is only 300px wide */
/* Long headings wrap rather than being cut off; the sidebar is 300px wide, 200px under 1400px */
overflow-wrap: break-word;
transition:
color 0.2s var(--ease-standard),

@ -77,6 +77,18 @@ const props = defineProps({
type: Boolean,
default: false
},
/**
* Width in px below which the drawer overlays the page instead of taking a column of its own.
*
* 1024 is the `md` breakpoint and what every drawer used before this was a prop; the site sidebar asks
* for 1100, because it is 255px wide beside an article that also gives up a contents column see
* `MainLayout`. Read once, at setup: a caller states this as a constant, not something that changes
* under a mounted drawer.
*/
overlayBelow: {
type: Number,
default: 1024
},
/**
* Light foreground, for a panel that is dark in both themes.
*
@ -96,11 +108,8 @@ const props = defineProps({
defineEmits(['update:modelValue'])
/**
* Where a drawer stops overlaying and takes its own column. 1024px is the `md` breakpoint, which is
* where the previous implementation switched too.
*/
const isWide = useMinWidth(1024)
/** Where this drawer stops overlaying and takes its own column. See `overlayBelow`. */
const isWide = useMinWidth(props.overlayBelow)
const isOverlay = computed(() => !isWide.value)

@ -164,6 +164,50 @@ body::-webkit-scrollbar-thumb {
}
}
// A button that sits IN a corner of the viewport rather than floating near it: square on the two edges
// it is flush against, and still fully round on the one corner that faces the page.
//
// `round` stays on the button, because that is what gives it its 3em square box and drops its padding --
// only the radius is restated here. Unlayered (this file is plain CSS, not a Tailwind layer), which is
// what lets it beat the `rounded-full` utility WBtn emits without `!important`.
//
// `50%` rather than a fixed radius: at half the box the curve runs from the middle of one edge to the
// middle of the next, so the corner is as round as the disc it was -- which is the point. A fixed value
// would have to be re-picked whenever the button's `size` changed.
//
// Here rather than in a layout, because three screens draw one: the site sidebar's opener and
// scroll-to-top in `MainLayout`, the contents panel's opener in `pages/Index.vue`, and the admin
// sidebar's opener in `AdminLayout` -- which is in a different layout tree and would otherwise see none
// of it. Which corner is the one facing the page depends on which corner the button is in, so the side is
// a modifier, stated after the base rule so each wins for its own corner.
.corner-btn {
border-radius: 0;
}
.corner-btn--left {
border-top-right-radius: 50%;
}
.corner-btn--right {
border-top-left-radius: 50%;
}
// The fade a corner button gives way with as the panel it opens slides over it. Same duration and easing
// as `WDrawer`'s own slide, so the two read as one movement.
.corner-btn-enter-active,
.corner-btn-leave-active {
transition: opacity 0.2s var(--ease-standard);
}
.corner-btn-enter-from,
.corner-btn-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.corner-btn-enter-active,
.corner-btn-leave-active {
transition-duration: 0.01ms;
}
}
// ------------------------------------------------------------------
// ICONS
// ------------------------------------------------------------------

@ -31,6 +31,13 @@
re-themed site follows.
*/
/*
The Sass breakpoint variables, loaded explicitly. `vite.config.js` injects this module into every SFC
through `additionalData`, but that reaches only the files Vite itself hands to Sass -- a partial pulled
in by `@use` from `app.scss` is resolved by Sass, and arrives without it.
*/
@use 'palette' as *;
.page-contents {
/* -- Palette ----------------------------------------------------------------- */
@ -319,6 +326,18 @@
color: var(--content-h1);
font-size: 2em;
letter-spacing: -0.015em;
/*
A step down on a phone, where 32px of display type is a fifth of the column's width and a long
title wraps to three lines before the article has started.
28px rather than further: it has to stay clearly above the 1.5em (24px) of the h2 under it, or the
two top levels stop reading as two -- and only this level moves, so the rest of the ramp descends
from a shorter first step rather than being redrawn.
*/
@media (max-width: $breakpoint-xs-max) {
font-size: 1.75em;
}
}
h2 {
@ -329,6 +348,19 @@
margin-top: 1.4em;
font-size: 1.5em;
letter-spacing: -0.01em;
/*
Down with the h1 on a phone, and by the same proportion, so the two top levels keep the distance
between them: 28px over 22px reads as a title over its sections, where 28 over 24 had them almost
level.
It closes to within 2px of the h3 below, which is further than the ramp would otherwise allow --
but a section heading is the one level that is also RULED, and a grey rule fading out across the
column separates it from a plain heading far more than two points of type ever did.
*/
@media (max-width: $breakpoint-xs-max) {
font-size: 1.375em;
}
}
/*

@ -66,7 +66,7 @@
</w-toolbar>
</div>
</w-header>
<w-drawer class="admin-sidebar" v-model="leftDrawerOpen" show-if-above bordered>
<w-drawer class="admin-sidebar" v-model="leftDrawerOpen" bordered>
<w-scroll-area class="admin-nav">
<w-list class="text-white pb-6" padding dense dark>
<w-item class="mb-2">
@ -433,6 +433,28 @@
</w-list>
</w-scroll-area>
</w-drawer>
<!--
The way back to the sidebar once it overlays the page instead of taking a column of its own, exactly
as `MainLayout` offers one: nothing else in the admin area opens it, and the header is a row of site
and account controls with no room for a menu button.
The position goes on a wrapper rather than on the button, as `WPageScroller` does it: `WBtn` is
`relative` from its own class list, and Tailwind emits `relative` after `fixed`, so a `fixed`
alongside it loses. `.corner-btn` is in `css/_base.scss`, since this layout never loads MainLayout's
stylesheet.
-->
<transition name="corner-btn">
<div v-if="showSidebarBtn" class="fixed bottom-0 left-0 z-30">
<w-btn
class="corner-btn corner-btn--left"
icon="la:bars"
color="primary"
round
size="md"
:aria-label="t(`admin.adminArea`)"
@click="narrowSidebarOpen = true" />
</div>
</transition>
<w-page-container class="admin-container">
<router-view v-slot="{ Component }"><component :is="Component" /></router-view>
<w-footer><footer-nav generic /></w-footer>
@ -449,6 +471,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useMeta } from '@/composables/meta'
import { useMinWidth } from '@/composables/screen'
import { useAdminStore } from '@/stores/admin'
import { useCommonStore } from '@/stores/common'
@ -508,10 +531,43 @@ useMeta(() => {
// DATA
const leftDrawerOpen = ref(true)
/**
* Whether the reader has opened the overlaying sidebar. Only consulted below the breakpoint, where the
* drawer is a panel over the page; above it the sidebar is a column that is simply there.
*/
const narrowSidebarOpen = ref(false)
// COMPUTED
/**
* Where the drawer stops overlaying the page and takes its own column `WDrawer`'s own default, which
* this layout leaves alone (unlike the site sidebar, which asks for 1100).
*/
const isWideViewport = useMinWidth(1024)
/**
* Whether the sidebar is on screen: always on a wide viewport, and only once asked for on a narrow one.
*
* It used to be `ref(true)` plus `show-if-above`, which had two consequences. On a narrow window the
* sidebar arrived open, over the page. And closing it there the scrim is the only way set the model to
* false, which is what `WDrawer` takes as its cue to stop applying `showIfAbove` for good: widening the
* window afterwards brought back neither the column nor any way to ask for it. Expressing the whole state
* here means the answer is recomputed from the width every time rather than latched once.
*/
const leftDrawerOpen = computed({
get: () => isWideViewport.value || narrowSidebarOpen.value,
// -> Only ever reached from the scrim, which exists only while overlaying
set: (val) => {
narrowSidebarOpen.value = val
}
})
/*
Shown only where the sidebar is something to open, and not while it is already open the scrim is what
closes it then, and the button would be behind the panel in any case.
*/
const showSidebarBtn = computed(() => !isWideViewport.value && !narrowSidebarOpen.value)
const siteSectionShown = computed(() => {
return (
userStore.can('manage:sites') ||
@ -554,6 +610,11 @@ function countBadgeClass(count) {
watch(
() => route.path,
async (newValue) => {
/*
Following a link out of the overlaying sidebar puts it away, since the section it leads to is behind
it. On a wide viewport there is nothing to close and the flag is not consulted anyway.
*/
narrowSidebarOpen.value = false
if (!newValue.startsWith('/_admin')) {
return
}

@ -7,6 +7,7 @@
class="bg-sidebar"
v-model="isSidebarOpen"
:width="sidebarWidth"
:overlay-below="SIDEBAR_OVERLAY_BELOW"
:side="siteStore.theme.sidebarPosition === `right` ? `right` : `left`">
<div v-if="isSidebarMini" class="sidebar-mini flex flex-col items-stretch">
<w-btn
@ -33,7 +34,7 @@
</w-btn>
<w-space />
<w-btn
v-if="canEditNav"
v-if="showEditNav"
class="py-1"
flat
icon="la:dharmachakra"
@ -79,7 +80,7 @@
</div>
<nav-sidebar />
<!-- -> Edit Nav is the whole bar now, so it is also what decides whether there is one -->
<w-bar v-if="canEditNav" class="sidebar-footerbtns text-white" dense>
<w-bar v-if="showEditNav" class="sidebar-footerbtns text-white" dense>
<w-btn class="flex-1" icon="la:dharmachakra" label="Edit Nav" flat>
<w-menu ref="navEditMenu" anchor="top left" self="bottom left" :offset="[0, 10]">
<nav-edit-menu
@ -103,10 +104,15 @@
The position goes on a wrapper rather than on the button, as `WPageScroller` does it: `WBtn` is
`relative` from its own class list, and Tailwind emits `relative` after `fixed`, so a `fixed`
alongside it loses.
Hard into the corner, with the corner facing the page rounded and the other three square -- see
`.corner-btn`. No margin, so the button is not a disc hovering near the edge of a small screen but
a piece of the screen's own corner, and every pixel of it is inside the viewport.
-->
<transition name="sidebar-open-btn">
<div v-if="showSidebarBtn" class="fixed bottom-4 left-4 z-30">
<transition name="corner-btn">
<div v-if="showSidebarBtn" class="fixed bottom-0 left-0 z-30">
<w-btn
class="corner-btn corner-btn--left"
icon="la:bars"
color="primary"
round
@ -123,13 +129,30 @@
-->
<w-page-container>
<router-view />
<!-- -> `.page-container-scrl` is the page view's article column, which is what scrolls -->
<!--
-> `.page-container-scrl` is the page view's article column, which is what scrolls
The mirror of the sidebar button in the opposite corner while the layout is in its narrow mode:
flush to the edge, and rounded on the top LEFT, since this is the corner it is tucked into from
the other side. It keeps the floating disc on a wide screen, where it has a seam to straddle
rather than a corner to sit in.
And it stands down below 750px, where the page view's contents panel takes this corner for its own
opener -- one button per corner, and there the contents are the more useful of the two. See
`showTocPanelBtn` in `pages/Index.vue`, which is what fills the gap.
-->
<w-page-scroller
v-if="isAtLeastTocPanelWidth"
:scroll-offset="150"
:offset="[15, 15]"
:offset="scrollerOffset"
:anchor-x="scrollerAnchorX"
target=".page-container-scrl">
<w-btn icon="la:arrow-up" color="primary" round size="md" />
<w-btn
:class="isWideViewport ? `` : `corner-btn corner-btn--right`"
icon="la:arrow-up"
color="primary"
round
size="md" />
</w-page-scroller>
</w-page-container>
<main-overlay-dialog />
@ -211,10 +234,38 @@ const isNarrowSidebarOpen = ref(false)
// COMPUTED
/**
* Where the drawer stops overlaying the page and takes its own column. Matches `WDrawer`'s own
* breakpoint below it there is no seam to straddle, because the sidebar is not beside anything.
* Where this sidebar stops overlaying the page and takes its own column of its own.
*
* 1100 rather than `WDrawer`'s default of 1024: this sidebar is 255px, and the page beside it gives up a
* contents column of its own before this point so by ~1050px the article is the narrowest of the three
* things sharing the window. Passed INTO the drawer rather than changed there, so the admin area's drawer
* keeps the 1024 it was written against.
*
* `NavSidebar` has to agree with it too: the dent marking the current page is only meaningful while the
* sidebar is beside the content. See `$sidebar-overlay-max` there.
*/
const SIDEBAR_OVERLAY_BELOW = 1100
/**
* The same boundary as a reactive flag, for everything in this layout that has to know which mode the
* drawer is in the scroll-to-top button's anchor and shape, and whether the sidebar needs an opener.
*/
const isWideViewport = useMinWidth(SIDEBAR_OVERLAY_BELOW)
/**
* The phone boundary the `sm` breakpoint from `css/tailwind.css`, and a different question from the one
* above: that one is about the LAYOUT (has the drawer got a column of its own), this one is about the
* DEVICE (is there a pointer, and room for an authoring control). See `showEditNav`.
*/
const isAtLeastSm = useMinWidth(600)
/**
* At or above 750px, which is where scroll-to-top keeps the bottom-right corner: below it the page view
* turns its contents column into a panel and puts the opener there instead. The page view owns that
* threshold (`$toc-overlay-max` and the 750px `useMinWidth` in `pages/Index.vue`); this is the same number
* from the side that has to get out of the way.
*/
const isWideViewport = useMinWidth(1024)
const isAtLeastTocPanelWidth = useMinWidth(750)
/** Whether this site, page and mode have a sidebar at all — before asking whether it is open. */
const isSidebarAvailable = computed(() => {
@ -280,6 +331,13 @@ const scrollerAnchorX = computed(() => {
: `${sidebarWidth.value}px`
})
/*
And no gap at all from the corner once it is in one: `[15, 15]` is the clearance a disc needs to read as
floating over the page, which is what it does on a wide screen. Squared into the corner there is nothing
to clear -- the button IS the corner.
*/
const scrollerOffset = computed(() => (isWideViewport.value ? [15, 15] : [0, 0]))
// -> The "Allow Browsing" site feature (admin/general): with it off the tree browser is not something
// a reader can reach, so the button that opens it does not render
const canBrowse = computed(() => siteStore.features.browse)
@ -287,10 +345,20 @@ const canBrowse = computed(() => siteStore.features.browse)
// -> The action bar holds only the locale menu and Browse; with both off it would be an empty strip
const showSidebarActions = computed(() => siteStore.locales.showMenu || canBrowse.value)
// -> Saving from this menu needs manage:navigation, so offering it to anyone else only produces a
// permission error once they press Save
const canEditNav = computed(() => {
return userStore.authenticated && userStore.can('manage:navigation')
/*
Whether to offer Edit Nav, in either of the two places the sidebar has for it -- the footer bar of the
full panel, and the small cog at the bottom of the icon rail. Two questions:
Saving from that menu needs `manage:navigation`, so offering it to anyone else only produces a
permission error once they press Save.
And not on a phone, whatever the permission: rearranging a navigation tree is drag-and-drop work in a
full-screen overlay, and the sidebar it hangs off is itself a panel the reader has just opened over the
page. Same call as the page header's authoring actions, at the same breakpoint -- an editing control
that needs a pointer is not offered on a screen that has none.
*/
const showEditNav = computed(() => {
return userStore.authenticated && userStore.can('manage:navigation') && isAtLeastSm.value
})
// WATCHERS
@ -330,26 +398,6 @@ function openSidebar() {
height: 100%;
}
/*
The menu button fades as the sidebar it opens slides, rather than blinking out from under the panel
the instant it is tapped. Same duration and easing as the drawer's own slide.
*/
.sidebar-open-btn-enter-active,
.sidebar-open-btn-leave-active {
transition: opacity 0.2s var(--ease-standard);
}
.sidebar-open-btn-enter-from,
.sidebar-open-btn-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.sidebar-open-btn-enter-active,
.sidebar-open-btn-leave-active {
transition-duration: 0.01ms;
}
}
/*
No background of its own, and nothing sticky: the drawer is the height of the shell and the nav list
above scrolls inside itself, so this bar sits at the bottom of the window by being last in the

@ -5,7 +5,27 @@
</w-header>
<w-page-container class="layout-profile">
<div class="layout-profile-card">
<div class="layout-profile-sd">
<!--
Below 900px the section list is a disclosure rather than a column beside the content: even shrunk
to its own labels it is ~240px, and on a phone the fixed 300px of it left the content overflowing
the card and clipped at the edge of the screen. Closed to start with, and it names the section
being read -- so the bar that opens the nav is also what says where in the profile the reader is.
-->
<w-btn
v-if="isNavCollapsed"
class="layout-profile-navbtn"
flat
no-caps
:icon="currentSection.icon"
:label="currentSection.label"
:aria-expanded="state.navOpen"
@click="toggleNav">
<w-icon
class="layout-profile-navchevron"
:class="{ 'is-open': state.navOpen }"
name="mdi:chevron-down" />
</w-btn>
<div class="layout-profile-sd" v-show="!isNavCollapsed || state.navOpen">
<w-list>
<template v-for="navItem of sidenav" :key="navItem.key">
<w-item
@ -56,10 +76,11 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { onMounted, reactive, watch } from 'vue'
import { computed, onMounted, reactive, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useMeta } from '@/composables/meta'
import { useMinWidth } from '@/composables/screen'
import { useFlagsStore } from '@/stores/flags'
import { useSiteStore } from '@/stores/site'
@ -138,11 +159,45 @@ const sidenav = [
}
]
const state = reactive({
/** Whether the section list is open. Only consulted below 900px, where it is a disclosure. */
navOpen: false
})
// COMPUTED
/**
* Below 900px, where the nav stops being a column beside the content and becomes a disclosure above it.
*
* This layout's own breakpoint rather than one of the app's: it is the width at which a nav column shrunk
* to its own labels (~240px, see the stylesheet) is still more than the content can spare. The stylesheet
* has to agree with it `$nav-collapse-max` is the same boundary from the other side.
*/
const isAtLeast900 = useMinWidth(900)
const isNavCollapsed = computed(() => !isAtLeast900.value)
/**
* The section being read, which is what the collapsed nav bar is labelled with.
*
* Falls back to the profile's own name for a path the list does not cover the public profile, or a
* `/_profile` with no section so the bar always says something.
*/
const currentSection = computed(() => {
return (
sidenav.find((item) => route.path === `/_profile/${item.key}`) ?? {
label: t('profile.title'),
icon: 'la:user-circle'
}
)
})
// WATCHERS
watch(
() => route.path,
async (newValue) => {
// -> Picking a section is what the open list is for, so arriving at one puts it away again
state.navOpen = false
if (!newValue.startsWith('/_profile')) {
return
}
@ -152,9 +207,26 @@ watch(
},
{ immediate: true }
)
// METHODS
function toggleNav() {
state.navOpen = !state.navOpen
}
</script>
<style lang="scss">
/*
Where this card's two desktop assumptions give out. Both are its own, not the app's -- see the comment
on the media queries at the bottom of this block. Stated as `max` values, just under the width the next
layout up starts at, the way `_palette.scss` states the shared ones.
`$nav-collapse-max` has to agree with the 900px `useMinWidth` above it, which is what decides whether
the disclosure button is rendered at all.
*/
$nav-collapse-max: 899.98px;
$nav-shrink-max: 1199.98px;
.layout-profile {
@at-root .body--light & {
background-color: $grey-3;
@ -333,6 +405,157 @@ watch(
z-index: 0;
}
}
/*
THREE NARROWER LAYOUTS
======================
This card is a sheet floating in a tinted page -- 90% of the width, 50px of gutter all round -- with a
300px nav column down its left side. Both of those are pitched for a desktop window, and they give out
at three different widths, so the card gives them up one at a time rather than all at once:
below 1200px the nav column stops being 300px wide and shrinks to its own labels, and the card's
gutters halve -- both of which hand the content back the width it is running out of
below 900px the nav column goes altogether and becomes a disclosure above the content, because
even shrunk to its labels it is ~240px that the content needs more; the settings rows
are still two columns here, which is the point of taking the nav out rather than
stacking them
below 600px the card stops being a sheet and becomes the screen, and the rows stack
Ordered narrowest-last, so each block overrides the one above it where the two speak about the same
property. `$nav-*-max` are this layout's own -- deliberately not in `_palette.scss`, which is for
breakpoints the whole app shares: these two describe when THIS card runs out of room, which is a
function of its own nav column and of nothing else.
*/
/* --- Below 1200px: the nav gives up its fixed width, the card gives up half its gutters ----------- */
@media (max-width: $nav-shrink-max) {
/*
Halved from `90% / 50px`. Deliberately not bracketed to the 900-1200 band: below 900 the gutters
would otherwise JUMP back to the wider pair as the window narrows, which is the one thing a reader
resizing a window would actually notice.
*/
&-card {
width: 95%;
margin: 25px auto;
}
/* -> `auto` basis: the column is as wide as its longest label needs, instead of 300px regardless */
&-sd {
flex: 0 0 auto;
}
}
/* --- Below 900px: the nav is a disclosure above the content ------------------------------------- */
@media (max-width: $nav-collapse-max) {
&-card {
flex-direction: column;
}
/*
The disclosure's bar. Full width, with the chevron pushed to the far end from the label, and the
card's own top corners -- it is the top of the card now, so it is what has to be rounded to it.
*/
&-navbtn {
justify-content: space-between;
border-radius: 7px 7px 0 0;
@at-root .body--light & {
background-color: $grey-1;
border-bottom: 1px solid $grey-3;
}
@at-root .body--dark & {
background-color: $dark-4;
border-bottom: 1px solid $dark-2;
}
}
/* -> The button's whole content is one flex row, so the chevron needs pushing to the end of it */
&-navbtn > span {
flex: 1;
justify-content: space-between;
}
&-navchevron {
transition: transform 0.2s var(--ease-standard);
&.is-open {
transform: rotate(180deg);
}
}
/*
The nav, no longer a column at all: the width of the card, with the seam that divided the two columns
moving from its right edge to its bottom one. Per theme, because that is where the rules being
replaced are declared -- at three classes each, which a plain override here would lose to.
*/
&-sd {
flex: none;
width: 100%;
border-radius: 0;
@at-root .body--light & {
border-right: 0;
border-bottom: 1px solid $grey-3;
box-shadow: none;
}
@at-root .body--dark & {
border-right: 0;
border-bottom: 1px solid rgba(#fff, 0.12);
box-shadow: none;
}
}
/* -> The seam is the nav's bottom border now, and a left one would draw down the content's own edge */
.w-page {
@at-root .body--light & {
border-left: 0;
}
@at-root .body--dark & {
border-left: 0;
}
}
}
/* --- Below 600px: the card is the screen, and a settings row stacks ----------------------------- */
@media (max-width: $breakpoint-xs-max) {
&-card {
width: 100%;
margin: 0;
border-radius: 0;
box-shadow: none;
}
/* -> Nothing left to round: the card's own corners are square here */
&-navbtn {
border-radius: 0;
}
/*
A settings row stacks: its label and its field are two MAIN sections, which share the row's width
equally -- 175px each on this screen, too narrow for either. The field takes a line of its own under
the label it belongs to, full width, and the 8px gutter between two columns becomes the gap between
two lines.
Scoped to `.w-page`, the content column: the nav's own rows are a side section and a main one, which
have no reason to wrap and would only be loosened by this.
*/
.w-page .w-item {
flex-wrap: wrap;
}
/*
`flex-basis`, not `width`: the section carries Tailwind's `flex-1`, which is `flex: 1 1 0%` -- and a
flex item is sized by its basis, so a width of 100% was simply ignored and the two sections went on
sharing the line. 100% is wider than the row can fit beside anything, which is what pushes it onto a
line of its own.
*/
.w-page .w-item-section--main + .w-item-section--main {
flex: 1 0 100%;
margin-top: 0.5rem;
margin-left: 0;
}
}
}
body.body--dark {

@ -43,7 +43,7 @@
</div>
<w-separator inset />
<div class="grid grid-cols-12 p-4 gap-4">
<div class="col-span-6">
<div class="col-span-12 lg:col-span-6">
<!-- ----------------------- -->
<!-- WIKI.JS -->
<!-- ----------------------- -->
@ -146,7 +146,7 @@
</w-item>
</w-card>
</div>
<div class="col-span-6">
<div class="col-span-12 lg:col-span-6">
<!-- ----------------------- -->
<!-- ENGINES -->
<!-- ----------------------- -->

@ -44,7 +44,7 @@
</div>
<w-separator inset />
<div class="grid grid-cols-12 p-4 gap-4">
<div class="col-span-6">
<div class="col-span-12 lg:col-span-6">
<!-- ----------------------- -->
<!-- Theme Options -->
<!-- ----------------------- -->
@ -213,7 +213,7 @@
</w-item>
</w-card>
</div>
<div class="col-span-6">
<div class="col-span-12 lg:col-span-6">
<!-- ----------------------- -->
<!-- Fonts -->
<!-- ----------------------- -->

@ -10,8 +10,10 @@
has neither to report -- the trail would end on a crumb that leads nowhere and the bar would read
"Last modified on N/A" -- so the missing-page screen below is the whole column.
-->
<!-- -> `py-1` on a phone: with the date gone the bar holds one line of small type, and 8px above and
below it made a strip nearly as tall as the crumbs themselves -->
<div
class="page-breadcrumbs py-2 px-4 flex flex-wrap"
class="page-breadcrumbs py-1 px-4 sm:py-2 flex flex-wrap"
v-if="!editorStore.isActive && !pageStore.notFound">
<div class="min-w-0 flex-1">
<w-breadcrumbs
@ -110,7 +112,9 @@
-->
<page-redirect v-else-if="pageStore.editor === `redirect`" />
<w-scroll-area class="page-container-scrl" ref="pageScroller" v-else style="height: 100%">
<div class="page-container-body p-4">
<!-- -> Half the padding on a phone, where 16px a side is 8% of the window spent on margin;
the stylesheet has `--content-bleed` to match -->
<div class="page-container-body p-2 sm:p-4">
<!--
Delegated rather than bound per link: the anchors are written by `v-html`, so there is
nothing here to put a handler on, and they are replaced wholesale on every render.
@ -190,10 +194,29 @@
</w-footer>
</w-scroll-area>
</div>
<!--
The scrim behind the contents panel while it is overlaying the article, which is also how it is
dismissed without picking a heading. Same treatment as the nav drawer's: see `WDrawer`.
-->
<transition name="page-sidebar-scrim">
<div v-if="tocPanelIsOpen" class="page-sidebar-scrim" @click="closeTocPanel" />
</transition>
<!--
The contents column. Below 750px it stops being a column and becomes a panel that slides in from
the right over the article -- see the stylesheet -- so it stays mounted at every width and it is
`is-open` that decides whether it is on screen.
The click handler closes it on the way out: any anchor inside it is something that takes the reader
somewhere (a heading, a tag), and a panel left over the place they were going would have to be
dismissed by hand. A `<button>` in here -- the tag editor's, the rating -- is not that, which is
why the test is `closest('a')` rather than any click at all.
-->
<div
class="page-sidebar"
v-if="showSidebar"
:style="siteStore.theme.tocPosition === `left` ? `order: 1;` : `order: 2;`">
:class="{ 'is-open': tocPanelIsOpen }"
:style="siteStore.theme.tocPosition === `left` ? `order: 1;` : `order: 2;`"
@click="onSidebarClick">
<template v-if="showToc">
<!-- TOC -->
<div class="p-4 flex items-center">
@ -274,6 +297,28 @@
<!-- -> Every action on it acts on a page: there is none here to edit, share, rate or delete -->
<page-actions-col v-if="!pageStore.notFound" />
</div>
<!--
What opens that panel, in the bottom-right corner -- the corner `MainLayout` gives to scroll-to-top,
which stands down below 750px so that this can have it. Same position and the same `.corner-btn`
shape (declared in `MainLayout`, which is always mounted above this view), so the two read as one
button that changes what it does rather than as two buttons fighting for a corner.
Not gated on having scrolled, as scroll-to-top is: the contents are how a reader decides where to go
in a long page, and that is most useful before they have gone anywhere.
-->
<transition name="toc-open-btn">
<div v-if="showTocPanelBtn" class="fixed bottom-0 right-0 z-30">
<w-btn
class="corner-btn corner-btn--right"
icon="mdi:file-tree"
color="primary"
round
size="md"
:aria-label="t(`common.page.contents`)"
:aria-expanded="tocPanelIsOpen"
@click="openTocPanel" />
</div>
</transition>
<side-dialog />
</w-page>
</template>
@ -295,6 +340,7 @@ import { useI18n } from 'vue-i18n'
import { useDark } from '@/composables/dark'
import { dialog } from '@/composables/dialog'
import { useMeta } from '@/composables/meta'
import { useMinWidth } from '@/composables/screen'
import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading'
import { scrollToAnchor, scrollToAnchorWhenReady } from '@/helpers/anchors'
@ -377,6 +423,11 @@ const state = reactive({
showTagsEditBtn: false,
tagEditMode: false,
tocSelected: null,
/**
* Whether the contents panel has been slid open. Only consulted below 750px, where the contents are a
* panel over the article rather than a column beside it.
*/
tocPanelOpen: false,
currentRating: 3
})
const pageContents = ref(null)
@ -385,6 +436,25 @@ const pageScroller = ref(null)
// COMPUTED
/**
* Below 750px, where the contents stop being a column beside the article and become a panel over it.
*
* This view's own threshold: at 200px (see `$toc-narrow-max`) the column still costs a third of a 600px
* window, and an article is what the reader came for. `MainLayout` has to agree with it that is where
* scroll-to-top gives up this corner and so does `$toc-overlay-max` in the stylesheet below.
*/
const isAtLeast750 = useMinWidth(750)
const tocIsPanel = computed(() => !isAtLeast750.value)
/** Whether the contents panel is on screen. Never true while the contents are a column. */
const tocPanelIsOpen = computed(() => tocIsPanel.value && showSidebar.value && state.tocPanelOpen)
/*
The opener: only where the contents are a panel, only on a page that has one to show, and not while it is
already open -- the scrim is what closes it then, and the button would be behind the panel in any case.
*/
const showTocPanelBtn = computed(() => tocIsPanel.value && showSidebar.value && !state.tocPanelOpen)
const showSidebar = computed(() => {
return (
pageStore.showSidebar &&
@ -593,7 +663,8 @@ watch(
return
}
// -> Load Page
// -> Load Page. The contents panel belongs to the page being left, so it goes with it
state.tocPanelOpen = false
scrollPageToTop()
try {
await pageStore.pageLoad({ path: newValue })
@ -767,6 +838,27 @@ function onContentClick(ev) {
router.push(target)
}
function openTocPanel() {
state.tocPanelOpen = true
}
function closeTocPanel() {
state.tocPanelOpen = false
}
/**
* Close the contents panel once the reader has picked something out of it.
*
* Delegated rather than bound per row: `PageToc` emits only `update:selected`, which does not fire again
* when the heading already showing is picked a second time so a click is the thing to listen for, not the
* selection changing. Any anchor counts, which is what also covers a tag.
*/
function onSidebarClick(ev) {
if (tocPanelIsOpen.value && ev.target?.closest?.('a')) {
closeTocPanel()
}
}
/** Asks for the page's password. Opened on arrival, and again from the lock screen's own button. */
function promptUnlock() {
dialog({ component: PageUnlockDialog })
@ -799,6 +891,20 @@ function goBack() {
</script>
<style lang="scss">
/*
Where the contents column stops being able to afford 300px. This 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 this
page's two sidebars. Stated as a `max` value just under 1400px, the way the shared ones are.
*/
$toc-narrow-max: 1399.98px;
/*
...and where it stops being a column at all and becomes a panel over the article. The same boundary as
the 750px `useMinWidth` above, which decides whether the opener is rendered, and as the one `MainLayout`
uses to stand scroll-to-top down from this corner. All three have to agree.
*/
$toc-overlay-max: 749.98px;
/*
The column in place of the article: the lock screen, the page that does not exist, and the
redirection on its way somewhere else. All three are the same shape -- a large faint icon, a
@ -856,10 +962,35 @@ function goBack() {
border-bottom: 1px solid $dark-3;
color: var(--color-white);
}
/*
A point off the trail on a phone, on the bar rather than on the crumbs: `WBreadcrumbs` sets no size
of its own and its icons are 125% of whatever it inherits, so one declaration here takes the text and
the icons down together and keeps the two in proportion.
13px is where it stops. The trail is how a reader gets back out, and it is already the smallest type
on the screen -- what is wanted is a bar that gives way to the page under it, not one nobody can read.
*/
@media (max-width: $breakpoint-xs-max) {
font-size: 0.8125rem;
}
}
.page-header {
height: 95px;
/*
Sized by its contents on a phone instead, which comes out around 70px: the 95px is pitched for a 64px
icon beside 34px display type, and holding it under the halved icon and title of the phone layout left
a band of empty gradient under the description.
`auto` rather than a smaller fixed height, because a fixed one is what the desktop bar can only just
afford: a title long enough to wrap has nowhere to go in it. Here the bar grows by a line instead, and
a page with no description gets a bar shorter still.
*/
@media (max-width: $breakpoint-xs-max) {
height: auto;
}
@at-root .body--light & {
background: linear-gradient(to bottom, $grey-2 0%, $grey-1 100%);
border-bottom: 1px solid $grey-4;
@ -906,6 +1037,25 @@ function goBack() {
}
.page-container-body {
flex: 1 0 auto;
/*
The other half of the padding change in the template above.
`--content-bleed` is how far the rule under an h1 reaches BACK through the padding of whatever holds
the content, so that it starts at the sidebar rather than at the text -- so it is a statement about
this surface's padding, and left at 1rem against 0.5rem of it the rule overhung the column by 8px.
`_page-contents.scss` declares the property expecting exactly this: a surface that pads differently
overrides the one property rather than the rule.
On the `.page-contents` element rather than here, because that is where the default is declared and a
custom property set on the parent would simply be shadowed by it. The editor's preview pane carries
the class itself and still pads 1rem, so it keeps the default.
*/
@media (max-width: $breakpoint-xs-max) {
.page-contents {
--content-bleed: 0.5rem;
}
}
}
.page-container {
@ -942,13 +1092,48 @@ function goBack() {
flex: 0 0 300px;
/*
Gone on a phone rather than narrowed: this column is a fixed 300px, so on a 390px screen it took
three quarters of the width and left the article a strip a few characters wide. There is no width to
share here, and the contents list, the tags and the rating are all things beside the page rather than
the page itself.
Narrower once the window is: 300px is pitched for a wide desktop, where it is a tenth of the width, and
by 1200px it is a quarter of what is left after the nav sidebar. 200px still holds a heading of a few
words per line -- the contents list wraps rather than truncating (see `PageToc`) -- and hands the
article the other 100px.
1400px is this view's own threshold rather than one of the app's `--breakpoint-*`: it is where THIS
column starts crowding the article, which depends on its own width and the nav's.
*/
@media (max-width: $toc-narrow-max) {
flex: 0 0 200px;
}
/*
And below 750px it stops being a column at all: even at 200px it is a third of a 600px window, and an
article is what the reader came for. It becomes a panel the width of the wide column, parked off the
right edge and slid in when asked for -- the same shape as the nav drawer on a narrow screen, and for
the same reason, so the two behave alike from opposite sides.
`position: fixed` is what takes it out of the row, so the article gets the whole width whether the
panel is open or not; the reader is never made to choose between the two, only to look at one at a
time. `transform` is what animates, being the one property that moves a box without laying anything
out again -- and the panel is out of flow, so there is nothing behind it to reflow anyway.
Right regardless of `tocPosition`: the opener is in the bottom-RIGHT corner, and a panel arriving from
the far side of the screen from the button that summoned it reads as something else appearing.
*/
@media (max-width: $breakpoint-xs-max) {
display: none;
@media (max-width: $toc-overlay-max) {
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 40;
/* -> The wide column's width, capped so it cannot take the whole of a small screen */
width: 300px;
max-width: 85vw;
transform: translateX(100%);
transition: transform 0.2s var(--ease-standard);
box-shadow: -2px 0 12px rgb(0 0 0 / 0.3);
&.is-open {
transform: none;
}
}
@at-root .body--light & {
@ -981,4 +1166,39 @@ function goBack() {
scrollbar-width: thin;
scrollbar-color: rgb(102 102 102 / 0.5) transparent;
}
/*
Behind the panel, and under it: the same tint and the same z-index as the nav drawer's scrim, one step
below the panel it dims. The opener is at z-30 as well and is not rendered while the panel is open, so
the two never overlap.
*/
.page-sidebar-scrim {
position: fixed;
inset: 0;
z-index: 30;
background-color: rgb(0 0 0 / 0.4);
}
.page-sidebar-scrim-enter-active,
.page-sidebar-scrim-leave-active,
.toc-open-btn-enter-active,
.toc-open-btn-leave-active {
transition: opacity 0.2s var(--ease-standard);
}
.page-sidebar-scrim-enter-from,
.page-sidebar-scrim-leave-to,
.toc-open-btn-enter-from,
.toc-open-btn-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.page-sidebar,
.page-sidebar-scrim-enter-active,
.page-sidebar-scrim-leave-active,
.toc-open-btn-enter-active,
.toc-open-btn-leave-active {
transition-duration: 0.01ms;
}
}
</style>

@ -1,8 +1,17 @@
<template>
<w-page class="py-4">
<div class="w-section-header">{{ t('profile.avatar') }}</div>
<div class="mt-10 flex flex-wrap gap-6">
<div class="flex-1 text-center">
<!--
-> `min-w-*` on both columns is what lets `flex-wrap` actually wrap them: `flex-1` is
`flex: 1 1 0%`, and an item whose basis is zero never overflows its line, so on a narrow screen
the two just squeezed instead -- the 180px avatar spilling off the left edge and the upload
column's text off the right. With a floor on each, two of them no longer fit side by side in a
column this narrow and the second takes its own line.
-->
<!-- -> `px-4` only while stacked: beside the avatar this column has the card's width around it, but
on its own line it starts at the very edge of the screen -->
<div class="mt-10 flex flex-wrap gap-6 px-4 sm:px-0">
<div class="min-w-60 flex-1 text-center">
<w-avatar
class="profile-avatar-circ"
size="180px"
@ -13,7 +22,7 @@
<w-icon v-else name="la:user" />
</w-avatar>
</div>
<div v-if="canEdit" class="flex-1 self-center">
<div v-if="canEdit" class="min-w-60 flex-1 self-center">
<div class="text-body1">{{ t('profile.avatarUploadTitle') }}</div>
<div class="text-caption">{{ t('profile.avatarUploadHint') }}</div>
<div class="mt-4">
@ -35,7 +44,7 @@
@click="clearImage" />
</div>
</div>
<div v-else class="flex-1 self-center">
<div v-else class="min-w-60 flex-1 self-center">
<div class="text-caption text-negative">{{ t('profile.avatarUploadDisabled') }}</div>
</div>
</div>

@ -14,7 +14,28 @@
t('common.actions.goback')
}}</w-tooltip>
</w-btn>
<div class="layout-search-sd">
<!--
Below 900px the sort and filter panel is a disclosure rather than a column: 300px of it beside a
390px screen left the results a 210px strip, and being a column of form fields it cannot be
narrowed to its content the way the profile's nav can. Closed to start with, because what a reader
arriving here wants is the results -- refining them is the second thing, and one tap away.
The chevron turns rather than being swapped for a second icon, so the two states are one drawing.
-->
<w-btn
v-if="isFiltersCollapsed"
class="layout-search-filterbtn"
flat
no-caps
:label="t(`search.filters`)"
:aria-expanded="state.filtersOpen"
@click="toggleFilters">
<w-icon
class="layout-search-filterchevron"
:class="{ 'is-open': state.filtersOpen }"
name="mdi:chevron-down" />
</w-btn>
<div class="layout-search-sd" v-show="!isFiltersCollapsed || state.filtersOpen">
<div class="section-header">{{ t('search.sortBy') }}</div>
<w-list dense padding>
<w-item
@ -214,6 +235,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { useMinWidth } from '@/composables/screen'
import { useFlagsStore } from '@/stores/flags'
import { useSiteStore } from '@/stores/site'
@ -271,6 +293,8 @@ useMeta(() => {
const state = reactive({
loading: 0,
/** Whether the sort/filter panel is open. Only consulted below 900px, where it is a disclosure. */
filtersOpen: false,
params: {
filterPath: '',
filterLocale: [],
@ -284,6 +308,18 @@ const state = reactive({
total: 0
})
/**
* Below 900px, where the filter panel stops being a column beside the results and becomes a disclosure
* above them.
*
* This layout's own breakpoint rather than one of the app's, and the same one `ProfileLayout` uses for its
* nav: the two screens are the same shape -- a card with a 300px sidebar -- so they run out of room at the
* same width. The stylesheet has to agree with it; `$filters-collapse-max` is the same boundary from the
* other side.
*/
const isAtLeast900 = useMinWidth(900)
const isFiltersCollapsed = computed(() => !isAtLeast900.value)
const orderByOptions = computed(() => {
return [
{ label: t('search.sortByRelevance'), value: 'relevancy', icon: 'la:stream' },
@ -336,6 +372,10 @@ function humanizeDate(val) {
return userStore.formatDateTime(t, val)
}
function toggleFilters() {
state.filtersOpen = !state.filtersOpen
}
function setOrderBy(val) {
if (val === state.params.orderBy) {
state.params.orderByDirection = state.params.orderByDirection === 'desc' ? 'asc' : 'desc'
@ -460,6 +500,17 @@ onUnmounted(() => {
</script>
<style lang="scss">
/*
Where this card's two desktop assumptions give out -- the same two widths `layouts/ProfileLayout.vue`
declares, because the two screens are the same shape and run out of room together. Deliberately not in
`_palette.scss`, which is for breakpoints the whole app shares; these describe one kind of card. Change
them in one file and the other wants the same change.
`$filters-collapse-max` has to agree with the 900px `useMinWidth` above it.
*/
$filters-collapse-max: 899.98px;
$card-gutter-max: 1199.98px;
.layout-search {
@at-root .body--light & {
background-color: $grey-3;
@ -608,6 +659,188 @@ onUnmounted(() => {
margin-right: 0;
}
}
/*
THREE NARROWER LAYOUTS
======================
Same shape and same thresholds as `layouts/ProfileLayout.vue`, which is the app's other card-beside-a-
sidebar screen: a sheet floating in a tinted page -- 90% of the width, 50px of gutter all round -- with
a 300px sidebar down its left side. Both give out as the window narrows, so the card gives them up one
at a time:
below 1200px the card's gutters halve, handing the results the width they are running out of. The
sidebar keeps its 300px, unlike the profile's nav: that one is a list of labels and
can be as narrow as they are, where this is a column of form fields
below 900px the sidebar goes altogether and becomes a disclosure above the results
below 600px the card stops being a sheet and becomes the screen, and a result row stacks
Ordered narrowest-last, so each block overrides the one above it where the two speak about the same
property. `$filters-collapse-max` is the stylesheet's half of the 900px `useMinWidth` above, which is
what decides whether the disclosure button is rendered at all.
*/
/* --- Below 1200px: the card gives up half its gutters ------------------------------------------- */
@media (max-width: $card-gutter-max) {
/*
Halved from `90% / 50px`. Not bracketed to a band: below 900 the gutters would otherwise jump back to
the wider pair as the window narrowed, which is the one thing a reader resizing a window notices.
*/
&-card {
width: 95%;
margin: 25px auto;
}
/*
And the back button goes with them. It is positioned into the gutter beside the card (`left: -50px`),
so it needs 50px of gutter to sit in -- which a 2.5% gutter is not at any width this rule covers, and
was not at 90% either much below 1000px: the circle was already being clipped by the left edge of the
window. Hidden rather than moved, because the header above still has the search field that brought
the reader here, and the browser still has its own Back.
*/
&-back {
display: none;
}
}
/* --- Below 900px: the sidebar is a disclosure above the results --------------------------------- */
@media (max-width: $filters-collapse-max) {
&-card {
flex-direction: column;
}
/*
The disclosure's bar. Full width, so it reads as a strip of the card rather than as a button sitting
on it -- `space-between` is what puts the chevron at the far end from the label, where a disclosure's
marker belongs -- and it takes the card's own top corners, being the top of the card now.
*/
&-filterbtn {
justify-content: space-between;
border-radius: 7px 7px 0 0;
@at-root .body--light & {
background-color: $grey-1;
border-bottom: 1px solid $grey-3;
}
@at-root .body--dark & {
background-color: $dark-4;
border-bottom: 1px solid $dark-2;
}
}
/* -> The whole content of the button is one flex row, so the chevron needs pushing to the end of it */
&-filterbtn > span {
flex: 1;
justify-content: space-between;
}
&-filterchevron {
transition: transform 0.2s var(--ease-standard);
&.is-open {
transform: rotate(180deg);
}
}
/*
The panel, no longer a 300px column: the full width of the card, and the seam that divided the two
columns moves from its right edge to its bottom one. Both stated per theme, because that is where
the rules they replace are declared -- at three classes each, which a plain override here would
lose to.
*/
&-sd {
flex: none;
width: 100%;
border-radius: 0;
@at-root .body--light & {
border-right: 0;
border-bottom: 1px solid $grey-3;
box-shadow: none;
}
@at-root .body--dark & {
border-right: 0;
border-bottom: 1px solid rgba(#fff, 0.12);
box-shadow: none;
}
}
/* -> The seam is the panel's bottom border now, and a left one would draw down the card's own edge */
.w-page {
@at-root .body--light & {
border-left: 0;
}
@at-root .body--dark & {
border-left: 0;
}
/*
The results header is the top-right corner of the card no longer -- the disclosure bar above it is
the whole top edge, and rounds both corners itself.
*/
.section-header:first-child {
border-top-right-radius: 0;
}
}
}
/* --- Below 600px: the card is the screen, and a result row stacks -------------------------------- */
@media (max-width: $breakpoint-xs-max) {
&-card {
width: 100%;
margin: 0;
border-radius: 0;
box-shadow: none;
}
/* -> Nothing left to round: the card's own corners are square here */
&-filterbtn {
border-radius: 0;
}
/*
A result stacks instead of reserving a column for its date and tags. That column is `shrink-0`, so
beside it a title had whatever was left -- and what was left of 390px, after an avatar and a date,
was a few words. Wrapped onto its own line the row reads as a card: icon and title, the path and the
matched text under it, then when it was touched and what it is tagged with.
*/
.w-page .w-list .w-item {
flex-wrap: wrap;
}
/*
And the icon goes to the top of the row rather than the middle of it. The section centres its
content, which is right for a row two lines tall and leaves the icon stranded halfway down one that
is now six.
*/
.w-page .w-list .w-item-section--avatar {
justify-content: flex-start;
}
.w-page .w-list .w-item-section--side:not(.w-item-section--avatar) {
width: 100%;
align-items: flex-start;
margin-top: 0.25rem;
/*
Lined up under the title rather than under the icon: 56px is the avatar column's own width
(`min-width` on `.w-item-section--avatar` in `WItemSection`), and it replaces the 16px this
section carries as a TRAILING one -- which is a gutter between two columns, and there is only one
column now.
*/
padding-left: 56px;
/*
Both were written for a right-hand column and are Tailwind utilities, so they are layered -- these
unlayered rules outrank them without `!important`.
*/
.text-right {
text-align: left;
}
.justify-end {
justify-content: flex-start;
}
}
}
}
body.body--dark {

Loading…
Cancel
Save