feat: additional accessibility options + static admin colors

pull/8104/head
NGPixel 2 days ago
parent 860a74aa31
commit 44188e8f10
No known key found for this signature in database

@ -174,6 +174,19 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
cvd: { cvd: {
type: 'string', type: 'string',
description: 'Color vision deficiency to adjust the palette for.' description: 'Color vision deficiency to adjust the palette for.'
},
reduceMotion: {
type: 'boolean',
description:
'Reduce motion whether or not the operating system asks for it. False follows the system setting.'
},
underlineLinks: {
type: 'boolean',
description: 'Underline every link in page content, rather than only on hover.'
},
contentTextSize: {
type: 'string',
description: 'How large page content is set. The interface around it is unaffected.'
} }
} }
}) })
@ -287,6 +300,16 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
cvd: { cvd: {
type: 'string', type: 'string',
enum: ['none', 'protanopia', 'deuteranopia', 'tritanopia'] enum: ['none', 'protanopia', 'deuteranopia', 'tritanopia']
},
reduceMotion: {
type: 'boolean'
},
underlineLinks: {
type: 'boolean'
},
contentTextSize: {
type: 'string',
enum: ['default', 'large', 'larger']
} }
} }
}) })

@ -81,7 +81,10 @@ const PERSONAL_PROFILE_FIELDS = [
'dateFormat', 'dateFormat',
'timeFormat', 'timeFormat',
'appearance', 'appearance',
'cvd' 'cvd',
'reduceMotion',
'underlineLinks',
'contentTextSize'
] as const ] as const
/** /**
@ -268,7 +271,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: "Update the logged in user's own profile", summary: "Update the logged in user's own profile",
description: description:
'Updates any subset of the profile fields; omitted ones are left unchanged. The name, location, job title and pronouns require profile editing to be enabled on this wiki (Administration → Authentication) and are refused otherwise; the language, time zone, date and time formats, appearance and colour-vision settings are the user’s own and are always accepted. The email cannot be changed here, and neither can any field an administrator owns.', 'Updates any subset of the profile fields; omitted ones are left unchanged. The name, location, job title and pronouns require profile editing to be enabled on this wiki (Administration → Authentication) and are refused otherwise; the language, time zone, date and time formats, appearance and accessibility settings are the user’s own and are always accepted. The email cannot be changed here, and neither can any field an administrator owns.',
tags: ['Users'], tags: ['Users'],
body: { body: {
$ref: 'UserProfileUpdate#' $ref: 'UserProfileUpdate#'
@ -311,7 +314,7 @@ async function routes(app: FastifyInstance) {
const patch: UserProfilePatch = {} const patch: UserProfilePatch = {}
for (const key of [...IDENTITY_PROFILE_FIELDS, ...PERSONAL_PROFILE_FIELDS] as const) { for (const key of [...IDENTITY_PROFILE_FIELDS, ...PERSONAL_PROFILE_FIELDS] as const) {
if (req.body[key] !== undefined) { if (req.body[key] !== undefined) {
patch[key] = req.body[key] ;(patch as Record<string, any>)[key] = req.body[key]
} }
} }
if (!WIKI.models.authentication.isProfileEditingAllowed()) { if (!WIKI.models.authentication.isProfileEditingAllowed()) {
@ -344,7 +347,10 @@ async function routes(app: FastifyInstance) {
dateFormat: profile.dateFormat, dateFormat: profile.dateFormat,
timeFormat: profile.timeFormat, timeFormat: profile.timeFormat,
appearance: profile.appearance, appearance: profile.appearance,
cvd: profile.cvd cvd: profile.cvd,
reduceMotion: profile.reduceMotion,
underlineLinks: profile.underlineLinks,
contentTextSize: profile.contentTextSize
} }
// -> The fields that were touched, not the values: `patch` carries whatever the profile form // -> The fields that were touched, not the values: `patch` carries whatever the profile form

@ -1238,6 +1238,7 @@
"admin.theme.codeBlocksAppearance": "Code Blocks Appearance", "admin.theme.codeBlocksAppearance": "Code Blocks Appearance",
"admin.theme.codeBlocksAppearanceHint": "The color theme used to display code blocks on pages.", "admin.theme.codeBlocksAppearanceHint": "The color theme used to display code blocks on pages.",
"admin.theme.codeInjection": "Code Injection", "admin.theme.codeInjection": "Code Injection",
"admin.theme.colorsHint": "These colors do not apply to the administration area, which always uses the default colors. Users who have enabled a color vision deficiency setting in their profile will also see different colors.",
"admin.theme.contentFont": "Content Font", "admin.theme.contentFont": "Content Font",
"admin.theme.contentFontHint": "The font used specifically for page content.", "admin.theme.contentFontHint": "The font used specifically for page content.",
"admin.theme.contentWidth": "Content Width", "admin.theme.contentWidth": "Content Width",
@ -1256,15 +1257,13 @@
"admin.theme.headHtmlInjection": "Head HTML Injection", "admin.theme.headHtmlInjection": "Head HTML Injection",
"admin.theme.headHtmlInjectionHint": "HTML code to be injected just before the closing head tag. Usually for script tags. Applied the next time a page is loaded.", "admin.theme.headHtmlInjectionHint": "HTML code to be injected just before the closing head tag. Usually for script tags. Applied the next time a page is loaded.",
"admin.theme.headerColor": "Header Color", "admin.theme.headerColor": "Header Color",
"admin.theme.headerColorHint": "The background color for the site top header. Does not apply to the administration area.", "admin.theme.headerColorHint": "The background color for the site top header.",
"admin.theme.iconset": "Icon Set", "admin.theme.iconset": "Icon Set",
"admin.theme.iconsetHint": "Set of icons to use for the sidebar navigation.", "admin.theme.iconsetHint": "Set of icons to use for the sidebar navigation.",
"admin.theme.layout": "Layout", "admin.theme.layout": "Layout",
"admin.theme.options": "Theme Options", "admin.theme.options": "Theme Options",
"admin.theme.primaryColor": "Primary Color", "admin.theme.primaryColor": "Primary Color",
"admin.theme.primaryColorHint": "The main color for primary action buttons and most form elements.", "admin.theme.primaryColorHint": "The main color for primary action buttons and most form elements.",
"admin.theme.reduceMotion": "Reduce Motion",
"admin.theme.reduceMotionHint": "Disable most site animations. This setting is automatically enforced when the reduced motion flag is enabled on the user OS.",
"admin.theme.resetDefaults": "Reset Defaults", "admin.theme.resetDefaults": "Reset Defaults",
"admin.theme.saveSuccess": "Theme configuration saved successfully!", "admin.theme.saveSuccess": "Theme configuration saved successfully!",
"admin.theme.secondaryColor": "Secondary Color", "admin.theme.secondaryColor": "Secondary Color",
@ -1272,7 +1271,7 @@
"admin.theme.showPrintBtn": "Show Print Button", "admin.theme.showPrintBtn": "Show Print Button",
"admin.theme.showPrintBtnHint": "Should the print button be displayed on all pages. Note that this doesn't prevent the user from printing the page using the system dialog.", "admin.theme.showPrintBtnHint": "Should the print button be displayed on all pages. Note that this doesn't prevent the user from printing the page using the system dialog.",
"admin.theme.sidebarColor": "Sidebar Color", "admin.theme.sidebarColor": "Sidebar Color",
"admin.theme.sidebarColorHint": "The background color for the side navigation menu on content pages. Does not apply to the administration area.", "admin.theme.sidebarColorHint": "The background color for the side navigation menu on content pages.",
"admin.theme.sidebarPosition": "Sidebar Position", "admin.theme.sidebarPosition": "Sidebar Position",
"admin.theme.sidebarPositionHint": "On which side should the main site navigation sidebar be displayed.", "admin.theme.sidebarPositionHint": "On which side should the main site navigation sidebar be displayed.",
"admin.theme.siteTheme": "Site Theme", "admin.theme.siteTheme": "Site Theme",
@ -2619,7 +2618,9 @@
"fileman.detailsDeletedAt": "Deleted", "fileman.detailsDeletedAt": "Deleted",
"fileman.detailsDeletedBy": "Deleted By", "fileman.detailsDeletedBy": "Deleted By",
"fileman.detailsPageCreated": "Created", "fileman.detailsPageCreated": "Created",
"fileman.detailsPageDescription": "Description",
"fileman.detailsPageEditor": "Editor", "fileman.detailsPageEditor": "Editor",
"fileman.detailsPageTags": "Tags",
"fileman.detailsPageType": "Type", "fileman.detailsPageType": "Type",
"fileman.detailsPageUpdated": "Last Updated", "fileman.detailsPageUpdated": "Last Updated",
"fileman.detailsPath": "Path", "fileman.detailsPath": "Path",
@ -2629,9 +2630,11 @@
"fileman.epsFileType": "EPS Image", "fileman.epsFileType": "EPS Image",
"fileman.excalidrawPageType": "Drawing", "fileman.excalidrawPageType": "Drawing",
"fileman.exeFileType": "Windows Executable", "fileman.exeFileType": "Windows Executable",
"fileman.fileCount": "1 file | {count} files",
"fileman.flacFileType": "FLAC Audio File", "fileman.flacFileType": "FLAC Audio File",
"fileman.folderChildrenCount": "Empty folder | 1 child | {count} children", "fileman.folderChildrenCount": "Empty folder | 1 child | {count} children",
"fileman.folderColor": "Set Folder Color", "fileman.folderColor": "Set Folder Color",
"fileman.folderCount": "1 folder | {count} folders",
"fileman.folderCreate": "New Folder", "fileman.folderCreate": "New Folder",
"fileman.folderDuplicate": "Duplicate Folder To...", "fileman.folderDuplicate": "Duplicate Folder To...",
"fileman.folderDuplicating": "Duplicating folder...", "fileman.folderDuplicating": "Duplicating folder...",
@ -2710,6 +2713,7 @@
"fileman.restorePathTaken": "Another page now exists at /{path}. Choose where to restore this page instead.", "fileman.restorePathTaken": "Another page now exists at /{path}. Choose where to restore this page instead.",
"fileman.restoreStale": "This page changed in the recycle bin since it was listed. The list has been refreshed.", "fileman.restoreStale": "This page changed in the recycle bin since it was listed. The list has been refreshed.",
"fileman.restoreSuccess": "Page restored successfully.", "fileman.restoreSuccess": "Page restored successfully.",
"fileman.rightClickHint": "Right-click an item to see its actions.",
"fileman.searchFolder": "Search folder...", "fileman.searchFolder": "Search folder...",
"fileman.showFolders": "Show Folders", "fileman.showFolders": "Show Folders",
"fileman.svgFileType": "Scalable Vector Graphic", "fileman.svgFileType": "Scalable Vector Graphic",
@ -2984,6 +2988,11 @@
"profile.avatarUploadInvalidType": "Must be a PNG, JPEG, WebP or GIF image.", "profile.avatarUploadInvalidType": "Must be a PNG, JPEG, WebP or GIF image.",
"profile.avatarUploadSuccess": "Profile picture uploaded successfully.", "profile.avatarUploadSuccess": "Profile picture uploaded successfully.",
"profile.avatarUploadTitle": "Upload your user profile picture.", "profile.avatarUploadTitle": "Upload your user profile picture.",
"profile.contentTextSize": "Content Text Size",
"profile.contentTextSizeDefault": "Default",
"profile.contentTextSizeHint": "Enlarge the text of page content. The rest of the interface keeps its size.",
"profile.contentTextSizeLarge": "Large",
"profile.contentTextSizeLarger": "Larger",
"profile.cvd": "Color Vision Deficiency", "profile.cvd": "Color Vision Deficiency",
"profile.cvdDeuteranopia": "Deuteranopia", "profile.cvdDeuteranopia": "Deuteranopia",
"profile.cvdHint": "Alter the color scheme of certain UI elements to account for certain color vision dificiencies.", "profile.cvdHint": "Alter the color scheme of certain UI elements to account for certain color vision dificiencies.",
@ -3041,6 +3050,8 @@
"profile.preferences": "Preferences", "profile.preferences": "Preferences",
"profile.pronouns": "Pronouns", "profile.pronouns": "Pronouns",
"profile.pronounsHint": "Let people know which pronouns should they use when referring to you.", "profile.pronounsHint": "Let people know which pronouns should they use when referring to you.",
"profile.reduceMotion": "Reduce Motion",
"profile.reduceMotionHint": "Turn off most animations and smooth scrolling. Always on if your operating system asks for reduced motion.",
"profile.save.success": "Profile saved successfully.", "profile.save.success": "Profile saved successfully.",
"profile.saveFailed": "Failed to save profile changes.", "profile.saveFailed": "Failed to save profile changes.",
"profile.saveSuccess": "Profile saved successfully.", "profile.saveSuccess": "Profile saved successfully.",
@ -3053,6 +3064,8 @@
"profile.timezone": "Timezone", "profile.timezone": "Timezone",
"profile.timezoneHint": "Set your timezone to display local time correctly.", "profile.timezoneHint": "Set your timezone to display local time correctly.",
"profile.title": "Profile", "profile.title": "Profile",
"profile.underlineLinks": "Underline Links",
"profile.underlineLinksHint": "Always underline links in page content, so they can be told apart without relying on color.",
"profile.uploadNewAvatar": "Upload New Image", "profile.uploadNewAvatar": "Upload New Image",
"profile.viewPublicProfile": "View Public Profile", "profile.viewPublicProfile": "View Public Profile",
"renderPageDialog.loading": "Queueing page render...", "renderPageDialog.loading": "Queueing page render...",

@ -130,6 +130,12 @@ export interface UserProfile {
timeFormat: string timeFormat: string
appearance: string appearance: string
cvd: string cvd: string
/** Reduce motion whether or not the operating system asks for it. False follows the system. */
reduceMotion: boolean
/** Underline every link in page content, rather than only on hover. */
underlineLinks: boolean
/** How large page content is set: `default`, `large` or `larger`. The interface around it is not. */
contentTextSize: string
} }
/** /**
@ -166,6 +172,9 @@ export interface UserProfilePatch {
timeFormat?: string timeFormat?: string
appearance?: string appearance?: string
cvd?: string cvd?: string
reduceMotion?: boolean
underlineLinks?: boolean
contentTextSize?: string
} }
/** The `meta` keys the profile owns, and the `prefs` keys it owns. */ /** The `meta` keys the profile owns, and the `prefs` keys it owns. */
@ -176,7 +185,10 @@ const profilePrefsKeys = [
'dateFormat', 'dateFormat',
'timeFormat', 'timeFormat',
'appearance', 'appearance',
'cvd' 'cvd',
'reduceMotion',
'underlineLinks',
'contentTextSize'
] as const ] as const
/** /**
@ -578,7 +590,10 @@ class Users {
dateFormat: WIKI.config.userDefaults?.dateFormat ?? 'YYYY-MM-DD', dateFormat: WIKI.config.userDefaults?.dateFormat ?? 'YYYY-MM-DD',
timeFormat: WIKI.config.userDefaults?.timeFormat ?? '12h', timeFormat: WIKI.config.userDefaults?.timeFormat ?? '12h',
appearance: 'site', appearance: 'site',
cvd: 'none' cvd: 'none',
reduceMotion: false,
underlineLinks: false,
contentTextSize: 'default'
} }
}) })
.returning({ id: usersTable.id }) .returning({ id: usersTable.id })
@ -653,7 +668,10 @@ class Users {
dateFormat: prefs.dateFormat ?? '', dateFormat: prefs.dateFormat ?? '',
timeFormat: prefs.timeFormat ?? '12h', timeFormat: prefs.timeFormat ?? '12h',
appearance: prefs.appearance ?? 'site', appearance: prefs.appearance ?? 'site',
cvd: prefs.cvd ?? 'none' cvd: prefs.cvd ?? 'none',
reduceMotion: prefs.reduceMotion ?? false,
underlineLinks: prefs.underlineLinks ?? false,
contentTextSize: prefs.contentTextSize ?? 'default'
} }
} }
@ -1412,7 +1430,10 @@ class Users {
dateFormat: 'YYYY-MM-DD', dateFormat: 'YYYY-MM-DD',
timeFormat: '12h', timeFormat: '12h',
appearance: 'site', appearance: 'site',
cvd: 'none' cvd: 'none',
reduceMotion: false,
underlineLinks: false,
contentTextSize: 'default'
} }
}, },
{ {
@ -1429,7 +1450,10 @@ class Users {
dateFormat: 'YYYY-MM-DD', dateFormat: 'YYYY-MM-DD',
timeFormat: '12h', timeFormat: '12h',
appearance: 'site', appearance: 'site',
cvd: 'none' cvd: 'none',
reduceMotion: false,
underlineLinks: false,
contentTextSize: 'default'
} }
} }
]) ])
@ -2485,7 +2509,10 @@ class Users {
dateFormat: user.prefs?.dateFormat, dateFormat: user.prefs?.dateFormat,
timeFormat: user.prefs?.timeFormat, timeFormat: user.prefs?.timeFormat,
appearance: user.prefs?.appearance, appearance: user.prefs?.appearance,
cvd: user.prefs?.cvd cvd: user.prefs?.cvd,
reduceMotion: user.prefs?.reduceMotion,
underlineLinks: user.prefs?.underlineLinks,
contentTextSize: user.prefs?.contentTextSize
} }
req.session.permissions = uniq(flatten(user.groups?.map((g: any) => g.permissions))) req.session.permissions = uniq(flatten(user.groups?.map((g: any) => g.permissions)))
// -> Group ids as well as their permissions, since navigation items are limited per group // -> Group ids as well as their permissions, since navigation items are limited per group

@ -33,6 +33,9 @@ declare module 'fastify' {
timeFormat?: string timeFormat?: string
appearance?: string appearance?: string
cvd?: string cvd?: string
reduceMotion?: boolean
underlineLinks?: boolean
contentTextSize?: string
} }
/** Flattened, de-duplicated permissions of every group the user belongs to. */ /** Flattened, de-duplicated permissions of every group the user belongs to. */
permissions?: string[] permissions?: string[]

@ -62,6 +62,7 @@ const i18n = useI18n({ useScope: 'global' })
// ROUTER // ROUTER
const router = useRouter() const router = useRouter()
const route = useRoute()
// STATE // STATE
@ -91,6 +92,16 @@ watch(
watch(() => commonStore.locale, applyLocale) watch(() => commonStore.locale, applyLocale)
watch(isAdminArea, () => {
applyTheme()
})
watch(
() => [userStore.reduceMotion, userStore.underlineLinks, userStore.contentTextSize],
applyAccessibility,
{ immediate: true }
)
// LOCALE // LOCALE
async function applyLocale(locale) { async function applyLocale(locale) {
@ -115,8 +126,41 @@ async function applyLocale(locale) {
document.documentElement.lang = locale document.documentElement.lang = locale
} }
// ACCESSIBILITY
/*
The reader's own accessibility settings (Profile -> Info), as classes on <body> -- the same element
`body--dark` is on, and for the same reason: it is above everything, teleported dialogs and menus
included. What each class does is next to the styles it changes: `css/_animation.scss` for motion,
`css/_page-contents.scss` for links and text size.
*/
function applyAccessibility() {
const classes = document.body.classList
classes.toggle('body--reduce-motion', userStore.reduceMotion)
classes.toggle('body--underline-links', userStore.underlineLinks)
classes.toggle('body--text-large', userStore.contentTextSize === 'large')
classes.toggle('body--text-larger', userStore.contentTextSize === 'larger')
}
// THEME // THEME
/*
The admin area is drawn in the wiki's own colours, not the site's. Its header and sidebar already
are, and a site theme was only ever chosen for reading that site -- a pale or clashing primary made
the admin controls hard to read, including on the very Theme screen that set it. These are the
defaults a new site starts with (`backend/models/sites.ts`). The colour vision setting still applies
on top, since that is the reader's need rather than the site's.
*/
const ADMIN_COLORS = {
primary: '#1976D2',
secondary: '#02C39A',
accent: '#FF9800'
}
function isAdminArea() {
return route.path === '/_admin' || route.path.startsWith('/_admin/')
}
async function applyTheme() { async function applyTheme() {
// -> Dark Mode // -> Dark Mode
if (userStore.appearance === 'site') { if (userStore.appearance === 'site') {
@ -126,9 +170,16 @@ async function applyTheme() {
} }
// -> CSS Vars // -> CSS Vars
setCssVar('primary', userStore.getAccessibleColor('primary', siteStore.theme.colorPrimary)) const brand = isAdminArea()
setCssVar('secondary', userStore.getAccessibleColor('secondary', siteStore.theme.colorSecondary)) ? ADMIN_COLORS
setCssVar('accent', userStore.getAccessibleColor('accent', siteStore.theme.colorAccent)) : {
primary: siteStore.theme.colorPrimary,
secondary: siteStore.theme.colorSecondary,
accent: siteStore.theme.colorAccent
}
setCssVar('primary', userStore.getAccessibleColor('primary', brand.primary))
setCssVar('secondary', userStore.getAccessibleColor('secondary', brand.secondary))
setCssVar('accent', userStore.getAccessibleColor('accent', brand.accent))
setCssVar('header', userStore.getAccessibleColor('header', siteStore.theme.colorHeader)) setCssVar('header', userStore.getAccessibleColor('header', siteStore.theme.colorHeader))
setCssVar('sidebar', userStore.getAccessibleColor('sidebar', siteStore.theme.colorSidebar)) setCssVar('sidebar', userStore.getAccessibleColor('sidebar', siteStore.theme.colorSidebar))
setCssVar('positive', userStore.getAccessibleColor('positive', '#02C39A')) setCssVar('positive', userStore.getAccessibleColor('positive', '#02C39A'))

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or 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. removing an icon; `check-icons.mjs` fails the build if this drifts.
284 icons. 285 icons.
*/ */
export const BUNDLED_ICONS = { export const BUNDLED_ICONS = {
"la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32}, "la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32},
@ -107,6 +107,7 @@ export const BUNDLED_ICONS = {
"la:microchip": {"body":"<path fill=\"currentColor\" d=\"M7 6v2H3v18h4v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h4V8h-4V6h-2v2h-2V6h-2v2h-2V6h-2v2h-2V6h-2v2H9V6zm-2 4h22v14H5zm3 2c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1M8 16c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m16 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1M8 20c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":32,"height":32}, "la:microchip": {"body":"<path fill=\"currentColor\" d=\"M7 6v2H3v18h4v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h4V8h-4V6h-2v2h-2V6h-2v2h-2V6h-2v2h-2V6h-2v2H9V6zm-2 4h22v14H5zm3 2c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1M8 16c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m16 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1M8 20c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":32,"height":32},
"la:minus": {"body":"<path fill=\"currentColor\" d=\"M5 15v2h22v-2z\"/>","width":32,"height":32}, "la:minus": {"body":"<path fill=\"currentColor\" d=\"M5 15v2h22v-2z\"/>","width":32,"height":32},
"la:mountain": {"body":"<path fill=\"currentColor\" d=\"m17.012 3.021l-.912 1.66l-6.522 11.856l-1.916-1.916l-.66 1.098l-5.86 9.767L.235 27h31.284l-.598-1.395l-3-7l-.582-1.357l-2.068 2.068l-7.403-14.605zm-.073 4.282l3.04 5.996l-.774.664l-2.28-1.953l-2.279 1.953l-.93-.799zm-.013 7.34l2.28 1.953l1.702-1.46l3.2 6.315l.622 1.233l1.932-1.932L28.482 25H3.766l4.293-7.154l1.988 1.988l.642-1.166l2.043-3.713l1.914 1.64z\"/>","width":32,"height":32}, "la:mountain": {"body":"<path fill=\"currentColor\" d=\"m17.012 3.021l-.912 1.66l-6.522 11.856l-1.916-1.916l-.66 1.098l-5.86 9.767L.235 27h31.284l-.598-1.395l-3-7l-.582-1.357l-2.068 2.068l-7.403-14.605zm-.073 4.282l3.04 5.996l-.774.664l-2.28-1.953l-2.279 1.953l-.93-.799zm-.013 7.34l2.28 1.953l1.702-1.46l3.2 6.315l.622 1.233l1.932-1.932L28.482 25H3.766l4.293-7.154l1.988 1.988l.642-1.166l2.043-3.713l1.914 1.64z\"/>","width":32,"height":32},
"la:mouse-pointer": {"body":"<path fill=\"currentColor\" d=\"M9 2.594v25.562l1.656-1.375l4.031-3.375l2.032 4.032l.437.906l.907-.469l3.093-1.594l.875-.437l-.437-.907l-1.844-3.625l5.063-.625l2.03-.25L25.407 19L10.72 4.281zm2 4.844l11.563 11.53l-4.5.532l-1.407.188l.657 1.28l2.062 4l-1.313.688l-2.156-4.312l-.594-1.125l-.968.812L11 23.844z\"/>","width":32,"height":32},
"la:newspaper": {"body":"<path fill=\"currentColor\" d=\"M3 5v18c0 2.21 1.79 4 4 4h18c2.21 0 4-1.79 4-4V12h-6V5zm2 2h16v16c0 .73.223 1.41.563 2H7c-1.191 0-2-.809-2-2zm2 2v5h12V9zm2 2h8v1H9zm14 3h4v9c0 1.191-.809 2-2 2s-2-.809-2-2zM7 15v2h5v-2zm7 0v2h5v-2zm-7 3v2h5v-2zm7 0v2h5v-2zm-7 3v2h5v-2zm7 0v2h5v-2z\"/>","width":32,"height":32}, "la:newspaper": {"body":"<path fill=\"currentColor\" d=\"M3 5v18c0 2.21 1.79 4 4 4h18c2.21 0 4-1.79 4-4V12h-6V5zm2 2h16v16c0 .73.223 1.41.563 2H7c-1.191 0-2-.809-2-2zm2 2v5h12V9zm2 2h8v1H9zm14 3h4v9c0 1.191-.809 2-2 2s-2-.809-2-2zM7 15v2h5v-2zm7 0v2h5v-2zm-7 3v2h5v-2zm7 0v2h5v-2zm-7 3v2h5v-2zm7 0v2h5v-2z\"/>","width":32,"height":32},
"la:otter": {"body":"<path fill=\"currentColor\" d=\"M14.5 5c-3.711 0-7.057 1.584-8.594 4.002A3.003 3.003 0 0 0 3 12c0 .899.407 2.021 1 2.818V27h2V14.068l-.268-.287C5.3 13.314 5 12.47 5 12a1 1 0 0 1 1-1q.097 0 .23.031l.784.188l.347-.729C8.347 8.435 11.283 7 14.5 7h3c3.217 0 6.153 1.435 7.139 3.49l.347.729l.784-.188q.133-.03.23-.031a1 1 0 0 1 1 1c0 .469-.3 1.314-.732 1.781l-.268.287V27h2V14.818c.593-.797 1-1.919 1-2.818a3.003 3.003 0 0 0-2.906-2.998C24.557 6.584 21.21 5 17.5 5zM10 12a1 1.5 0 0 0 0 3a1 1.5 0 0 0 0-3m12 0a1 1.5 0 0 0 0 3a1 1.5 0 0 0 0-3m-6 1.018c-.915 0-1.83.323-2.629.968c-3.557 2.878-3.64 2.95-3.887 3.157l-.107.09C8.489 17.974 8 18.958 8 20c0 2.206 1.794 4 4 4c1.498 0 2.914-.36 4-1.006C17.086 23.64 18.502 24 20 24c2.206 0 4-1.794 4-4c0-1.041-.489-2.024-1.377-2.768l-.107-.09c-.246-.206-.33-.279-3.887-3.158c-.798-.644-1.714-.966-2.629-.966m0 1.986c.463 0 .927.179 1.371.537c3.527 2.854 3.613 2.926 3.856 3.13l.113.097c.3.252.66.669.66 1.232c0 1.103-.897 2-2 2c-1.339 0-2.6-.371-3.371-.994l-.596-.48c.075-.87.353-1.526.967-1.526c1.105 0 2-.202 2-.727C19 17.57 17.657 17 16 17s-3 .57-3 1.273c0 .525.895.727 2 .727c.614 0 .892.656.967 1.525l-.596.48C14.6 21.63 13.34 22 12 22c-1.103 0-2-.897-2-2c0-.562.36-.98.662-1.232l.113-.096c.244-.205.328-.276 3.854-3.129c.445-.36.908-.54 1.371-.54z\"/>","width":32,"height":32}, "la:otter": {"body":"<path fill=\"currentColor\" d=\"M14.5 5c-3.711 0-7.057 1.584-8.594 4.002A3.003 3.003 0 0 0 3 12c0 .899.407 2.021 1 2.818V27h2V14.068l-.268-.287C5.3 13.314 5 12.47 5 12a1 1 0 0 1 1-1q.097 0 .23.031l.784.188l.347-.729C8.347 8.435 11.283 7 14.5 7h3c3.217 0 6.153 1.435 7.139 3.49l.347.729l.784-.188q.133-.03.23-.031a1 1 0 0 1 1 1c0 .469-.3 1.314-.732 1.781l-.268.287V27h2V14.818c.593-.797 1-1.919 1-2.818a3.003 3.003 0 0 0-2.906-2.998C24.557 6.584 21.21 5 17.5 5zM10 12a1 1.5 0 0 0 0 3a1 1.5 0 0 0 0-3m12 0a1 1.5 0 0 0 0 3a1 1.5 0 0 0 0-3m-6 1.018c-.915 0-1.83.323-2.629.968c-3.557 2.878-3.64 2.95-3.887 3.157l-.107.09C8.489 17.974 8 18.958 8 20c0 2.206 1.794 4 4 4c1.498 0 2.914-.36 4-1.006C17.086 23.64 18.502 24 20 24c2.206 0 4-1.794 4-4c0-1.041-.489-2.024-1.377-2.768l-.107-.09c-.246-.206-.33-.279-3.887-3.158c-.798-.644-1.714-.966-2.629-.966m0 1.986c.463 0 .927.179 1.371.537c3.527 2.854 3.613 2.926 3.856 3.13l.113.097c.3.252.66.669.66 1.232c0 1.103-.897 2-2 2c-1.339 0-2.6-.371-3.371-.994l-.596-.48c.075-.87.353-1.526.967-1.526c1.105 0 2-.202 2-.727C19 17.57 17.657 17 16 17s-3 .57-3 1.273c0 .525.895.727 2 .727c.614 0 .892.656.967 1.525l-.596.48C14.6 21.63 13.34 22 12 22c-1.103 0-2-.897-2-2c0-.562.36-.98.662-1.232l.113-.096c.244-.205.328-.276 3.854-3.129c.445-.36.908-.54 1.371-.54z\"/>","width":32,"height":32},
"la:paper-plane": {"body":"<path fill=\"currentColor\" d=\"m3.594 5.344l.437 1.875L5.97 16l-1.94 8.781l-.437 1.875l1.781-.718l22-9L29.656 16l-2.281-.938l-22-9zm2.781 3.312L21.906 15H7.781zM7.781 17h14.125L6.375 23.344z\"/>","width":32,"height":32}, "la:paper-plane": {"body":"<path fill=\"currentColor\" d=\"m3.594 5.344l.437 1.875L5.97 16l-1.94 8.781l-.437 1.875l1.781-.718l22-9L29.656 16l-2.281-.938l-22-9zm2.781 3.312L21.906 15H7.781zM7.781 17h14.125L6.375 23.344z\"/>","width":32,"height":32},

@ -213,4 +213,10 @@ function initials(name) {
opacity: 0.45; opacity: 0.45;
} }
} }
/* -> Twinned under the Reduce Motion profile setting; see `css/_animation.scss` */
.body--reduce-motion .collab-presence-person.is-typing .collab-presence-wave {
animation: none;
transform: scale(1.35);
opacity: 0.45;
}
</style> </style>

@ -327,6 +327,7 @@ import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { scrollBehavior } from '@/helpers/motion'
import { enhanceRenderedContent } from '@/helpers/renderedContent' import { enhanceRenderedContent } from '@/helpers/renderedContent'
import { debounce } from 'es-toolkit/function' import { debounce } from 'es-toolkit/function'
@ -1123,7 +1124,7 @@ function scrollPreviewTo(container, el) {
const offset = el.getBoundingClientRect().top - container.getBoundingClientRect().top const offset = el.getBoundingClientRect().top - container.getBoundingClientRect().top
container.scrollTo({ container.scrollTo({
top: container.scrollTop + offset - container.clientHeight * PREVIEW_CONTEXT_ABOVE, top: container.scrollTop + offset - container.clientHeight * PREVIEW_CONTEXT_ABOVE,
behavior: 'smooth' behavior: scrollBehavior()
}) })
} }
@ -1528,7 +1529,7 @@ onMounted(async () => {
return return
} }
if (currentLine < 3) { if (currentLine < 3) {
container.scrollTo({ top: 0, behavior: 'smooth' }) container.scrollTo({ top: 0, behavior: scrollBehavior() })
return return
} }
const anchor = previewAnchorFor(container, currentLine) const anchor = previewAnchorFor(container, currentLine)

@ -356,6 +356,7 @@ import {
writeBlockContent writeBlockContent
} from '@/helpers/markdownBlocks' } from '@/helpers/markdownBlocks'
import { findEditableTables } from '@/helpers/markdownTable' import { findEditableTables } from '@/helpers/markdownTable'
import { scrollBehavior } from '@/helpers/motion'
import EditorCodeBlockMenu from '@/components/EditorCodeBlockMenu.vue' import EditorCodeBlockMenu from '@/components/EditorCodeBlockMenu.vue'
import EditorEmojiMenu from '@/components/EditorEmojiMenu.vue' import EditorEmojiMenu from '@/components/EditorEmojiMenu.vue'
@ -1301,7 +1302,7 @@ function scrollPreviewTo(container, el) {
const offset = el.getBoundingClientRect().top - container.getBoundingClientRect().top const offset = el.getBoundingClientRect().top - container.getBoundingClientRect().top
container.scrollTo({ container.scrollTo({
top: container.scrollTop + offset - container.clientHeight * PREVIEW_CONTEXT_ABOVE, top: container.scrollTop + offset - container.clientHeight * PREVIEW_CONTEXT_ABOVE,
behavior: 'smooth' behavior: scrollBehavior()
}) })
} }
@ -1774,7 +1775,7 @@ onMounted(async () => {
return return
} }
if (currentLine < 3) { if (currentLine < 3) {
container.scrollTo({ top: 0, behavior: 'smooth' }) container.scrollTo({ top: 0, behavior: scrollBehavior() })
return return
} }
const anchor = previewAnchorFor(container, currentLine) const anchor = previewAnchorFor(container, currentLine)

@ -149,56 +149,85 @@
</div> </div>
</w-drawer> </w-drawer>
<w-drawer class="fileman-right" :model-value="detailsPaneShown" :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="flex h-full flex-col">
<div class="p-4"> <w-scroll-area
<template v-if="state.isRecycleBin"> class="min-h-0 flex-1"
<template v-if="selectedDeletedPage"> :thumb-style="thumbStyle"
<img :bar-style="barStyle"
class="mb-4 w-full rounded object-cover" style="height: 100%">
src="/_assets/illustrations/fileman-page.svg" /> <div class="p-4">
<div class="fileman-details-row" v-for="item of deletedPageDetails" :key="item.label"> <template v-if="state.isRecycleBin">
<label>{{ item.label }}</label> <template v-if="selectedDeletedPage">
<span>{{ item.value }}</span> <img
</div> class="mb-4 w-full rounded object-cover"
src="/_assets/illustrations/fileman-page.svg" />
<div
class="fileman-details-row"
v-for="item of deletedPageDetails"
:key="item.label">
<label>{{ item.label }}</label>
<span>{{ item.value }}</span>
</div>
</template>
</template> </template>
</template> <template v-else-if="currentFileDetails">
<template v-else-if="currentFileDetails"> <!--
<!--
A button around the thumbnail, and only for an image: it opens the full view, and the A button around the thumbnail, and only for an image: it opens the full view, and the
illustration standing in for a page has nothing behind it to open. illustration standing in for a page has nothing behind it to open.
--> -->
<button <button
class="fileman-details-thumb w-unstyled mb-4 block w-full" class="fileman-details-thumb w-unstyled mb-4 block w-full"
v-if="currentFileDetails.thumbnail && currentFileDetails.viewable" v-if="currentFileDetails.thumbnail && currentFileDetails.viewable"
:aria-label="t(`common.actions.view`)" :aria-label="t(`common.actions.view`)"
@click="viewCurrentFile"> @click="viewCurrentFile">
<img class="w-full rounded object-cover" :src="currentFileDetails.thumbnail" /> <img class="w-full rounded object-cover" :src="currentFileDetails.thumbnail" />
</button> </button>
<img <img
class="mb-4 w-full rounded object-cover" class="mb-4 w-full rounded object-cover"
v-else-if="currentFileDetails.thumbnail" v-else-if="currentFileDetails.thumbnail"
:src="currentFileDetails.thumbnail" /> :src="currentFileDetails.thumbnail" />
<div <div
class="fileman-details-row" class="fileman-details-row"
v-for="item of currentFileDetails.items" v-for="item of currentFileDetails.items"
:key="item.id"> :key="item.id">
<label>{{ item.label }}</label> <label>{{ item.label }}</label>
<span>{{ item.value }}</span> <div v-if="item.tags" class="mt-1 flex flex-wrap gap-1">
</div> <w-chip
<template v-if="insertMode"> v-for="tag of item.tags"
<w-separator class="my-4" /> :key="tag"
<w-btn square
class="w-full" dense
@click="insertItem()" color="secondary"
:label="t(`common.actions.insert`)" text-color="white">
color="primary" <w-icon class="me-1" name="la:hashtag" size="14px" />
icon="la:plus-circle" <span class="text-caption">{{ tag }}</span>
push </w-chip>
padding="sm" /> </div>
<span v-else>{{ item.value }}</span>
</div>
<template v-if="insertMode">
<w-separator class="my-4" />
<w-btn
class="w-full"
@click="insertItem()"
:label="t(`common.actions.insert`)"
color="primary"
icon="la:plus-circle"
push
padding="sm" />
</template>
</template> </template>
</template> </div>
</w-scroll-area>
<!--
Pinned under the details, the way the Recycle Bin is pinned under the tree: every action on a
file, a folder or a deleted page is in its right-click menu, and nothing on screen says so.
-->
<div class="fileman-hint">
<w-icon name="la:mouse-pointer" size="sm" />
<span>{{ t('fileman.rightClickHint') }}</span>
</div> </div>
</w-scroll-area> </div>
</w-drawer> </w-drawer>
<w-page-container> <w-page-container>
<!-- <!--
@ -700,9 +729,21 @@
</w-page-container> </w-page-container>
<w-footer> <w-footer>
<w-bar class="fileman-path"> <w-bar class="fileman-path">
<small class="text-caption text-grey-7">{{ <!--
-> `flex-1` on the path is what puts the counts at the far end: `ms-auto` cannot, because
`WBar` gives every child after the first a start margin from outside Tailwind's layers,
which beats any utility
-->
<small class="text-caption text-grey-7 min-w-0 flex-1 truncate">{{
state.isRecycleBin ? t('fileman.recycleBin') : folderPath state.isRecycleBin ? t('fileman.recycleBin') : folderPath
}}</small> }}</small>
<!--
-> What the folder holds, not what the list is showing: the search box and the "show folders"
option are ways of looking at the folder, and the bar beside them describes the folder
-->
<small v-if="listCounts" class="text-caption text-grey-7 shrink-0 ps-4">{{
listCounts
}}</small>
</w-bar> </w-bar>
</w-footer> </w-footer>
<input type="file" ref="fileIpt" multiple @change="uploadNewFiles" style="display: none" /> <input type="file" ref="fileIpt" multiple @change="uploadNewFiles" style="display: none" />
@ -973,6 +1014,25 @@ function listingOrder(a, b) {
return Number(b.type === 'folder') - Number(a.type === 'folder') return Number(b.type === 'folder') - Number(a.type === 'folder')
} }
/**
* The counts in the bar under the list, or null while there is nothing settled to count -- or
* nothing to count at all. A kind the folder holds none of is left out rather than reported as zero,
* and the Recycle Bin, holding deleted pages and nothing else, never has folders to report.
*/
const listCounts = computed(() => {
if (state.isRecycleBin ? state.binLoading : state.fileListLoading) {
return null
}
const items = state.isRecycleBin ? state.binItems : state.fileList
const folders = state.isRecycleBin ? 0 : items.filter((f) => f.type === 'folder').length
const files = items.length - folders
const parts = [
...(folders > 0 ? [t('fileman.folderCount', { count: folders }, folders)] : []),
...(files > 0 ? [t('fileman.fileCount', { count: files }, files)] : [])
]
return parts.length > 0 ? parts.join(' · ') : null
})
const files = computed(() => { const files = computed(() => {
return filteredFiles.value return filteredFiles.value
.filter((f) => { .filter((f) => {
@ -1076,6 +1136,19 @@ const currentFileDetails = computed(() => {
switch (item.type) { switch (item.type) {
case 'page': { case 'page': {
thumbnail = '/_assets/illustrations/fileman-page.svg' thumbnail = '/_assets/illustrations/fileman-page.svg'
// -> Both optional on a page, and left out rather than shown as an empty row when not set
if (item.description) {
items.push({
label: t('fileman.detailsPageDescription'),
value: item.description
})
}
if (item.tags.length > 0) {
items.push({
label: t('fileman.detailsPageTags'),
tags: item.tags
})
}
items.push({ items.push({
label: t('fileman.detailsPageType'), label: t('fileman.detailsPageType'),
value: t(`fileman.${item.pageType}PageType`) value: t(`fileman.${item.pageType}PageType`)
@ -1337,6 +1410,8 @@ async function loadTree({ parentId = null, parentPath = null, types, initLoad =
type: 'page', type: 'page',
title: item.title, title: item.title,
pageType: item.editor || 'markdown', pageType: item.editor || 'markdown',
description: item.description,
tags: item.tags ?? [],
folderPath: item.folderPath, folderPath: item.folderPath,
fileName: item.fileName, fileName: item.fileName,
createdAt: item.createdAt, createdAt: item.createdAt,
@ -2279,6 +2354,15 @@ onBeforeUnmount(() => {
*/ */
$fileman-hdr-wrap-max: 899.98px; $fileman-hdr-wrap-max: 899.98px;
/*
The one height shared by the three rows along the bottom -- the Recycle Bin under the tree, the path
bar under the list, and the hint under the details -- so they read as a single band across the view.
45px is what the two side rows come to on their own: a 1px rule, 10px of padding each way, a 24px
icon. A minimum, not a height, so a translation long enough to wrap grows its row instead of being
clipped; it also wins over the `height` `WBar` sets without a specificity fight.
*/
$fileman-bottom-row-height: 45px;
.fileman { .fileman {
/* /*
THE HEADER ON A NARROW SCREEN THE HEADER ON A NARROW SCREEN
@ -2437,6 +2521,25 @@ $fileman-hdr-wrap-max: 899.98px;
} }
} }
&-hint {
display: flex;
align-items: center;
gap: 8px;
min-height: $fileman-bottom-row-height;
padding: 10px 16px;
font-size: 12px;
line-height: 1.4;
@at-root .body--light & {
border-top: 1px solid rgba(0, 0, 0, 0.08);
color: $grey-7;
}
@at-root .body--dark & {
border-top: 1px solid rgba(255, 255, 255, 0.1);
color: $grey-5;
}
}
&-toolbar { &-toolbar {
@at-root .body--light & { @at-root .body--light & {
background-color: $grey-1; background-color: $grey-1;
@ -2455,6 +2558,7 @@ $fileman-hdr-wrap-max: 899.98px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
min-height: $fileman-bottom-row-height;
width: 100%; width: 100%;
padding: 10px 12px; padding: 10px 12px;
font: inherit; font: inherit;
@ -2463,18 +2567,24 @@ $fileman-hdr-wrap-max: 899.98px;
cursor: pointer; cursor: pointer;
transition: background-color 0.4s ease; transition: background-color 0.4s ease;
/*
-> A shade under the drawer at rest, because the path bar beside it is the drawer's own fill and
the two would otherwise run together as one strip along the bottom
*/
@at-root .body--light & { @at-root .body--light & {
border-top: 1px solid rgba(0, 0, 0, 0.08); border-top: 1px solid rgba(0, 0, 0, 0.08);
background-color: rgba(0, 0, 0, 0.04);
} }
@at-root .body--dark & { @at-root .body--dark & {
border-top: 1px solid rgba(255, 255, 255, 0.1); border-top: 1px solid rgba(255, 255, 255, 0.1);
background-color: rgba(0, 0, 0, 0.2);
} }
&:hover, &:hover,
&:focus-visible, &:focus-visible,
&.is-active { &.is-active {
@at-root .body--light & { @at-root .body--light & {
background-color: rgba(0, 0, 0, 0.05); background-color: rgba(0, 0, 0, 0.09);
} }
@at-root .body--dark & { @at-root .body--dark & {
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);
@ -2497,11 +2607,16 @@ $fileman-hdr-wrap-max: 899.98px;
} }
&-path { &-path {
min-height: $fileman-bottom-row-height;
// -> The same rule the Recycle Bin draws along its top, so the bottom band has one edge across it
@at-root .body--light & { @at-root .body--light & {
background-color: $blue-grey-1 !important; background-color: $blue-grey-1 !important;
border-top: 1px solid rgba(0, 0, 0, 0.08);
} }
@at-root .body--dark & { @at-root .body--dark & {
background-color: $dark-4 !important; background-color: $dark-4 !important;
border-top: 1px solid rgba(255, 255, 255, 0.1);
} }
} }
@ -2614,7 +2729,8 @@ $fileman-hdr-wrap-max: 899.98px;
color: $blue-grey-4; color: $blue-grey-4;
} }
} }
span { // -> The row's own value only: a tag chip draws its label in a span too, white on its own fill
> span {
font-size: 0.85rem; font-size: 0.85rem;
@at-root .body--light & { @at-root .body--light & {

@ -83,4 +83,8 @@
animation-duration: 2s; animation-duration: 2s;
} }
} }
/* -> Twinned under the Reduce Motion profile setting; see `css/_animation.scss` */
.body--reduce-motion .loader-generic > div:before {
animation-duration: 2s;
}
</style> </style>

@ -195,6 +195,8 @@ import { sortBy } from 'es-toolkit/array'
import { cloneDeep } from 'es-toolkit/object' import { cloneDeep } from 'es-toolkit/object'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import { scrollBehavior } from '@/helpers/motion'
// PROPS // PROPS
const props = defineProps({ const props = defineProps({
@ -280,7 +282,7 @@ watch(
if (newValue) { if (newValue) {
nextTick(() => { nextTick(() => {
scrollAreaEnd.value.scrollIntoView({ scrollAreaEnd.value.scrollIntoView({
behavior: 'smooth' behavior: scrollBehavior()
}) })
}) })
} }

@ -993,6 +993,10 @@ function notImplemented() {
animation: none; animation: none;
} }
} }
/* -> Twinned under the Reduce Motion profile setting; see `css/_animation.scss` */
.body--reduce-motion .is-ringing :deep(svg) {
animation: none;
}
/* /*
The two headings, while they are also the fields. The two headings, while they are also the fields.

@ -409,6 +409,8 @@
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { scrollBehavior } from '@/helpers/motion'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { usePageStore } from '@/stores/page' import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
@ -539,7 +541,7 @@ function removeRelation(rel) {
} }
function jumpToSection(id) { function jumpToSection(id) {
document.querySelector(`#${id}`).scrollIntoView({ document.querySelector(`#${id}`).scrollIntoView({
behavior: 'smooth' behavior: scrollBehavior()
}) })
} }
function toggleRequirePassword(newValue) { function toggleRequirePassword(newValue) {
@ -547,7 +549,7 @@ function toggleRequirePassword(newValue) {
nextTick(() => { nextTick(() => {
iptPagePassword.value.focus() iptPagePassword.value.focus()
iptPagePassword.value.$el.scrollIntoView({ iptPagePassword.value.$el.scrollIntoView({
behavior: 'smooth' behavior: scrollBehavior()
}) })
}) })
} else { } else {

@ -306,6 +306,56 @@
]" /> ]" />
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-separator class="my-2" inset />
<w-item tag="label">
<blueprint-icon icon="swipe-right" />
<w-item-section>
<w-item-label>{{ t(`profile.reduceMotion`) }}</w-item-label>
<w-item-label caption>{{ t(`profile.reduceMotionHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.user.prefs.reduceMotion"
:disable="!canManage"
:aria-label="t(`profile.reduceMotion`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item tag="label">
<blueprint-icon icon="underline" />
<w-item-section>
<w-item-label>{{ t(`profile.underlineLinks`) }}</w-item-label>
<w-item-label caption>{{ t(`profile.underlineLinksHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.user.prefs.underlineLinks"
:disable="!canManage"
:aria-label="t(`profile.underlineLinks`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="typography" />
<w-item-section>
<w-item-label>{{ t(`profile.contentTextSize`) }}</w-item-label>
<w-item-label caption>{{ t(`profile.contentTextSizeHint`) }}</w-item-label>
</w-item-section>
<w-item-section class="flex-none">
<w-btn-toggle
v-model="state.user.prefs.contentTextSize"
:disable="!canManage"
push
glossy
no-caps
toggle-color="primary"
:options="[
{ value: 'default', label: t('profile.contentTextSizeDefault') },
{ value: 'large', label: t('profile.contentTextSizeLarge') },
{ value: 'larger', label: t('profile.contentTextSizeLarger') }
]" />
</w-item-section>
</w-item>
</w-card> </w-card>
</div> </div>
<div class="col-span-12 lg:col-span-4"> <div class="col-span-12 lg:col-span-4">

@ -94,4 +94,8 @@ const circumference = computed(() => 2 * Math.PI * radius.value)
animation-duration: 4s; animation-duration: 4s;
} }
} }
/* -> Twinned under the Reduce Motion profile setting; see `css/_animation.scss` */
.body--reduce-motion .w-circular-progress__arc {
animation-duration: 4s;
}
</style> </style>

@ -209,4 +209,12 @@ const barStyle = computed(() => ({
animation: none; animation: none;
} }
} }
/* -> Twinned under the Reduce Motion profile setting; see `css/_animation.scss` */
.body--reduce-motion .w-linear-progress-indeterminate {
animation-duration: 3s;
}
.body--reduce-motion .w-linear-progress-striped,
.body--reduce-motion .w-linear-progress-bar:not(.w-linear-progress-indeterminate) {
animation: none;
}
</style> </style>

@ -151,4 +151,9 @@ const { t } = useI18n()
animation: none; animation: none;
} }
} }
/* -> Twinned under the Reduce Motion profile setting; see `css/_animation.scss` */
.body--reduce-motion .w-notification-progress,
.body--reduce-motion .w-notification-count {
animation: none;
}
</style> </style>

@ -14,6 +14,8 @@
<script setup> <script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue' import { onBeforeUnmount, onMounted, ref } from 'vue'
import { scrollBehavior } from '@/helpers/motion'
/** /**
* "Back to top" affordance that appears once the page has been scrolled past `scrollOffset`, and * "Back to top" affordance that appears once the page has been scrolled past `scrollOffset`, and
* smooth-scrolls to the top when clicked. * smooth-scrolls to the top when clicked.
@ -21,7 +23,7 @@ import { onBeforeUnmount, onMounted, ref } from 'vue'
* Always flush against the bottom of the viewport, in one of two places: the bottom-right corner, or * Always flush against the bottom of the viewport, in one of two places: the bottom-right corner, or
* with its right edge at `anchorX` — which is how it tucks into the bottom of the nav sidebar's * with its right edge at `anchorX` — which is how it tucks into the bottom of the nav sidebar's
* column. Scrolling uses the platform's own smooth behaviour rather than the hand-rolled easing the * column. Scrolling uses the platform's own smooth behaviour rather than the hand-rolled easing the
* previous component shipped, and honours `prefers-reduced-motion` for free. * previous component shipped, and jumps instead when motion is to be reduced (`helpers/motion.js`).
*/ */
const props = defineProps({ const props = defineProps({
/** Show once the window has scrolled this many pixels. */ /** Show once the window has scrolled this many pixels. */
@ -64,8 +66,7 @@ function onScroll() {
} }
function scrollToTop() { function scrollToTop() {
// -> `smooth` is ignored when the user has asked for reduced motion, which is the behaviour we want ;(scroller() ?? window).scrollTo({ top: 0, behavior: scrollBehavior() })
;(scroller() ?? window).scrollTo({ top: 0, behavior: 'smooth' })
} }
onMounted(() => { onMounted(() => {

@ -172,4 +172,9 @@ const style = computed(() => ({
animation: none; animation: none;
} }
} }
/* -> Twinned under the Reduce Motion profile setting; see `css/_animation.scss` */
.body--reduce-motion .w-signal__ring,
.body--reduce-motion .w-signal__core {
animation: none;
}
</style> </style>

@ -76,4 +76,8 @@ const style = computed(() => {
animation-duration: 2s; animation-duration: 2s;
} }
} }
/* -> Twinned under the Reduce Motion profile setting; see `css/_animation.scss` */
.body--reduce-motion .w-spinner {
animation-duration: 2s;
}
</style> </style>

@ -155,3 +155,34 @@
opacity: 0; opacity: 0;
} }
} }
/*
The reader's own Reduce Motion setting (Profile -> Info), for when the operating system does not
say so -- a shared or managed machine, or a system setting that is not the reader's to change.
`App.vue` puts the class on <body>; the operating system's answer still applies with it off.
CSS cannot write one rule for "this media query OR this class", so every rule that reacts to
`prefers-reduced-motion` needs a twin under `body.body--reduce-motion`. For TRANSITIONS this one
rule is that twin: every component's own reduced-motion rule sets them to 0.01ms, so doing it to all
of them at once is the same thing, and a colour fade that had no rule of its own simply becomes
instant. ANIMATIONS are not uniform -- a spinner is slowed rather than stopped, since a frozen one
reads as a hung page -- so each component that has one carries its own twin beside its media query.
A block's shadow root is out of reach of both halves, as it is of every other global rule.
*/
body.body--reduce-motion {
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
}
.animated {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
}
.animated[class*='Out'] {
opacity: 0;
}
}

@ -238,7 +238,23 @@
--content-code-deletion-wash: rgba(207, 34, 46, 0.12); --content-code-deletion-wash: rgba(207, 34, 46, 0.12);
color: var(--content-ink); color: var(--content-ink);
font-size: 1rem; /*
Scaled by the reader's own Content Text Size setting (Profile -> Info), which `App.vue` sets as a
class on <body>. Only the article: the interface around it keeps its size, which is what makes
this different from zooming the browser. Headings and most of what is inside the article are set
in `em` and follow on their own; the few in `rem` that are TEXT (code, a code block's title) take
the same factor, while those that are furniture -- the heading anchor, the step discs, the copy
tooltip -- stay the size of the interface they belong to.
*/
--content-scale: 1;
font-size: calc(1rem * var(--content-scale));
@at-root .body--text-large & {
--content-scale: 1.125;
}
@at-root .body--text-larger & {
--content-scale: 1.25;
}
/* /*
1.6, not the 1.7 the documentation platforms tend to set: at this measure the looser leading read 1.6, not the 1.7 the documentation platforms tend to set: at this measure the looser leading read
as gappy rather than airy, with the lines of a paragraph no longer holding together as a block. as gappy rather than airy, with the lines of a paragraph no longer holding together as a block.
@ -712,6 +728,18 @@
text-underline-offset: 2px; text-underline-offset: 2px;
} }
/*
The reader's Underline Links setting (Profile -> Info): a link marked by colour alone is the one
thing a colour vision deficiency cannot be adjusted around. Under `:where()` so the class adds no
specificity -- the links that are deliberately not underlined because their shape already says
what they are (a footnote number, a row of a links list) keep saying so.
*/
:where(.body--underline-links) & {
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
/* /*
A link that leaves the wiki says so. A link that leaves the wiki says so.
@ -1376,7 +1404,7 @@
background-color: var(--content-surface-code); background-color: var(--content-surface-code);
color: var(--content-ink); color: var(--content-ink);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.8125rem; font-size: calc(0.8125rem * var(--content-scale));
/* -> Tracks the body's leading; code at 13px needs a little more of it, not much */ /* -> Tracks the body's leading; code at 13px needs a little more of it, not much */
line-height: 1.55; line-height: 1.55;
/* -> Code does not wrap: a wrapped line is a different line, and indentation stops meaning anything */ /* -> Code does not wrap: a wrapped line is a different line, and indentation stops meaning anything */
@ -1617,7 +1645,7 @@
border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent); border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent);
background-color: color-mix(in srgb, currentColor 7%, transparent); background-color: color-mix(in srgb, currentColor 7%, transparent);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.75rem; font-size: calc(0.75rem * var(--content-scale));
font-weight: 600; font-weight: 600;
line-height: 1.5; line-height: 1.5;
/* -> A file path with no spaces in it is the commonest title there is */ /* -> A file path with no spaces in it is the commonest title there is */

@ -8,6 +8,8 @@
* each block fetches its component and settles into its real size. * each block fetches its component and settles into its real size.
*/ */
import { scrollBehavior } from './motion'
/** /**
* Asked of a block that might be hiding the element the event was dispatched on. * Asked of a block that might be hiding the element the event was dispatched on.
* *
@ -103,8 +105,7 @@ function driftOf(el, scroller) {
} }
function scrollTo(el, smooth) { function scrollTo(el, smooth) {
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches el.scrollIntoView({ behavior: smooth ? scrollBehavior() : 'auto', block: 'start' })
el.scrollIntoView({ behavior: smooth && !reduceMotion ? 'smooth' : 'auto', block: 'start' })
} }
/** /**

@ -0,0 +1,18 @@
/**
* Whether motion should be reduced: because the operating system says so, or because the reader
* turned on Reduce Motion in their profile, which `App.vue` records as a class on <body>.
*
* For motion decided in JavaScript, which neither half of the stylesheet side reaches -- a scroll
* asked for with `behavior: 'smooth'` animates whatever `prefers-reduced-motion` says.
*/
export function prefersReducedMotion() {
return (
document.body.classList.contains('body--reduce-motion') ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
)
}
/** The `behavior` for a `scrollTo()` / `scrollIntoView()` that would otherwise be smooth. */
export function scrollBehavior() {
return prefersReducedMotion() ? 'auto' : 'smooth'
}

@ -1368,4 +1368,8 @@ onMounted(() => {
animation: none; animation: none;
} }
} }
/* -> Twinned under the Reduce Motion profile setting; see `css/_animation.scss` */
.body--reduce-motion .status-dot--alert {
animation: none;
}
</style> </style>

@ -104,6 +104,11 @@
</w-item-section> </w-item-section>
</w-item> </w-item>
</template> </template>
<w-card-section>
<w-banner :class="dark.isActive ? `bg-teal-9 text-white` : `bg-teal-1 text-teal-9`">{{
t('admin.theme.colorsHint')
}}</w-banner>
</w-card-section>
</w-card> </w-card>
<!-- ----------------------- --> <!-- ----------------------- -->
<!-- Code Blocks --> <!-- Code Blocks -->
@ -328,6 +333,7 @@
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { onMounted, reactive, watch } from 'vue' import { onMounted, reactive, watch } from 'vue'
import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta' import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading' import { loading } from '@/composables/loading'
@ -340,6 +346,10 @@ import { toMerged } from 'es-toolkit/object'
import { startCase } from 'es-toolkit/string' import { startCase } from 'es-toolkit/string'
import UtilCodeEditor from '../components/UtilCodeEditor.vue' import UtilCodeEditor from '../components/UtilCodeEditor.vue'
// DARK MODE
const dark = useDark()
// STORES // STORES
const adminStore = useAdminStore() const adminStore = useAdminStore()

@ -239,6 +239,46 @@
:aria-label="t(`profile.cvd`)" /> :aria-label="t(`profile.cvd`)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-separator inset spaced="sm" />
<w-item tag="label">
<blueprint-icon icon="swipe-right" />
<w-item-section>
<w-item-label>{{ t(`profile.reduceMotion`) }}</w-item-label>
<w-item-label caption>{{ t(`profile.reduceMotionHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle v-model="state.config.reduceMotion" :aria-label="t(`profile.reduceMotion`)" />
</w-item-section>
</w-item>
<w-separator inset spaced="sm" />
<w-item tag="label">
<blueprint-icon icon="underline" />
<w-item-section>
<w-item-label>{{ t(`profile.underlineLinks`) }}</w-item-label>
<w-item-label caption>{{ t(`profile.underlineLinksHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle v-model="state.config.underlineLinks" :aria-label="t(`profile.underlineLinks`)" />
</w-item-section>
</w-item>
<w-separator inset spaced="sm" />
<w-item>
<blueprint-icon icon="typography" />
<w-item-section>
<w-item-label>{{ t(`profile.contentTextSize`) }}</w-item-label>
<w-item-label caption>{{ t(`profile.contentTextSizeHint`) }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-btn-toggle
v-model="state.config.contentTextSize"
push
glossy
no-caps
toggle-color="primary"
:options="contentTextSizes"
:aria-label="t(`profile.contentTextSize`)" />
</w-item-section>
</w-item>
<!-- -> Always: the preferences and accessibility settings below are savable whether or not the <!-- -> Always: the preferences and accessibility settings below are savable whether or not the
information above is editable --> information above is editable -->
<div class="actions-bar mt-6"> <div class="actions-bar mt-6">
@ -298,7 +338,10 @@ const state = reactive({
dateFormat: '', dateFormat: '',
timeFormat: '12h', timeFormat: '12h',
appearance: 'site', appearance: 'site',
cvd: 'none' cvd: 'none',
reduceMotion: false,
underlineLinks: false,
contentTextSize: 'default'
}, },
loading: 0 loading: 0
}) })
@ -344,6 +387,11 @@ const cvdChoices = computed(() => [
{ value: 'deuteranopia', label: t('profile.cvdDeuteranopia') }, { value: 'deuteranopia', label: t('profile.cvdDeuteranopia') },
{ value: 'tritanopia', label: t('profile.cvdTritanopia') } { value: 'tritanopia', label: t('profile.cvdTritanopia') }
]) ])
const contentTextSizes = computed(() => [
{ value: 'default', label: t('profile.contentTextSizeDefault') },
{ value: 'large', label: t('profile.contentTextSizeLarge') },
{ value: 'larger', label: t('profile.contentTextSizeLarger') }
])
const timezones = Intl.supportedValuesOf('timeZone') const timezones = Intl.supportedValuesOf('timeZone')
const canEdit = computed(() => authConfigStore.allowProfileEditing) const canEdit = computed(() => authConfigStore.allowProfileEditing)
@ -384,6 +432,9 @@ function applyProfile(profile) {
state.config.timeFormat = profile.timeFormat || '12h' state.config.timeFormat = profile.timeFormat || '12h'
state.config.appearance = profile.appearance || 'site' state.config.appearance = profile.appearance || 'site'
state.config.cvd = profile.cvd || 'none' state.config.cvd = profile.cvd || 'none'
state.config.reduceMotion = profile.reduceMotion ?? false
state.config.underlineLinks = profile.underlineLinks ?? false
state.config.contentTextSize = profile.contentTextSize || 'default'
} }
async function save() { async function save() {
@ -413,7 +464,10 @@ async function save() {
dateFormat: state.config.dateFormat, dateFormat: state.config.dateFormat,
timeFormat: state.config.timeFormat, timeFormat: state.config.timeFormat,
appearance: state.config.appearance, appearance: state.config.appearance,
cvd: state.config.cvd cvd: state.config.cvd,
reduceMotion: state.config.reduceMotion,
underlineLinks: state.config.underlineLinks,
contentTextSize: state.config.contentTextSize
} }
}).json() }).json()
if (!resp?.ok) { if (!resp?.ok) {
@ -422,8 +476,8 @@ async function save() {
if (resp.profile) { if (resp.profile) {
applyProfile(resp.profile) applyProfile(resp.profile)
} }
// -> Only the fields the store actually holds: the appearance and CVD choices are watched by the // -> Only the fields the store actually holds: the appearance and accessibility choices are
// app shell, so saving them takes effect right away // watched by the app shell, so saving them takes effect right away
userStore.$patch({ userStore.$patch({
name: state.config.name, name: state.config.name,
locale: state.config.locale, locale: state.config.locale,
@ -431,7 +485,10 @@ async function save() {
dateFormat: state.config.dateFormat, dateFormat: state.config.dateFormat,
timeFormat: state.config.timeFormat, timeFormat: state.config.timeFormat,
appearance: state.config.appearance, appearance: state.config.appearance,
cvd: state.config.cvd cvd: state.config.cvd,
reduceMotion: state.config.reduceMotion,
underlineLinks: state.config.underlineLinks,
contentTextSize: state.config.contentTextSize
}) })
notify({ notify({
type: 'positive', type: 'positive',

@ -102,6 +102,9 @@ export const useUserStore = defineStore('user', {
timeFormat: '12h', timeFormat: '12h',
appearance: 'site', appearance: 'site',
cvd: 'none', cvd: 'none',
reduceMotion: false,
underlineLinks: false,
contentTextSize: 'default',
permissions: [], permissions: [],
pagePermissions: [], pagePermissions: [],
authenticated: false, authenticated: false,
@ -149,6 +152,9 @@ export const useUserStore = defineStore('user', {
timeFormat: resp.timeFormat || '12h', timeFormat: resp.timeFormat || '12h',
appearance: resp.appearance || 'site', appearance: resp.appearance || 'site',
cvd: resp.cvd || 'none', cvd: resp.cvd || 'none',
reduceMotion: resp.reduceMotion ?? false,
underlineLinks: resp.underlineLinks ?? false,
contentTextSize: resp.contentTextSize || 'default',
permissions: resp.permissions || [], permissions: resp.permissions || [],
authenticated: true, authenticated: true,
profileLoaded: true profileLoaded: true
@ -180,6 +186,9 @@ export const useUserStore = defineStore('user', {
timeFormat: '12h', timeFormat: '12h',
appearance: 'site', appearance: 'site',
cvd: 'none', cvd: 'none',
reduceMotion: false,
underlineLinks: false,
contentTextSize: 'default',
permissions: [], permissions: [],
// -> Page permissions arrive with the page, so leaving them would keep edit buttons on screen // -> Page permissions arrive with the page, so leaving them would keep edit buttons on screen
// for a user who is no longer logged in until they navigate // for a user who is no longer logged in until they navigate

Loading…
Cancel
Save