From 44188e8f1067772185411b3a7b298671b3fb27e9 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Sat, 26 Sep 2026 20:59:49 -0400 Subject: [PATCH] feat: additional accessibility options + static admin colors --- backend/api/schemas/user.ts | 23 ++ backend/api/users.ts | 14 +- backend/locales/en.json | 21 +- backend/models/users.ts | 39 +++- backend/types/fastify.d.ts | 3 + frontend/src/App.vue | 57 ++++- frontend/src/assets/icons.generated.js | 3 +- frontend/src/components/CollabPresence.vue | 6 + frontend/src/components/EditorAsciidoc.vue | 5 +- frontend/src/components/EditorMarkdown.vue | 5 +- frontend/src/components/FileManager.vue | 210 ++++++++++++++---- frontend/src/components/LoadingGeneric.vue | 4 + .../src/components/PageDataTemplateDialog.vue | 4 +- frontend/src/components/PageHeader.vue | 4 + .../src/components/PagePropertiesDialog.vue | 6 +- frontend/src/components/UserEditOverlay.vue | 50 +++++ .../components/shared/WCircularProgress.vue | 4 + .../src/components/shared/WLinearProgress.vue | 8 + .../src/components/shared/WNotifications.vue | 5 + .../src/components/shared/WPageScroller.vue | 7 +- frontend/src/components/shared/WSignal.vue | 5 + frontend/src/components/shared/WSpinner.vue | 4 + frontend/src/css/_animation.scss | 31 +++ frontend/src/css/_page-contents.scss | 34 ++- frontend/src/helpers/anchors.js | 5 +- frontend/src/helpers/motion.js | 18 ++ frontend/src/pages/AdminStorage.vue | 4 + frontend/src/pages/AdminTheme.vue | 10 + frontend/src/pages/ProfileInfo.vue | 67 +++++- frontend/src/stores/user.js | 9 + 30 files changed, 580 insertions(+), 85 deletions(-) create mode 100644 frontend/src/helpers/motion.js diff --git a/backend/api/schemas/user.ts b/backend/api/schemas/user.ts index 6e53d7ef1..8886f0df0 100644 --- a/backend/api/schemas/user.ts +++ b/backend/api/schemas/user.ts @@ -174,6 +174,19 @@ export async function registerSchemas(app: FastifyInstance): Promise { cvd: { type: 'string', 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 { cvd: { type: 'string', enum: ['none', 'protanopia', 'deuteranopia', 'tritanopia'] + }, + reduceMotion: { + type: 'boolean' + }, + underlineLinks: { + type: 'boolean' + }, + contentTextSize: { + type: 'string', + enum: ['default', 'large', 'larger'] } } }) diff --git a/backend/api/users.ts b/backend/api/users.ts index b999e9787..b7639e4da 100644 --- a/backend/api/users.ts +++ b/backend/api/users.ts @@ -81,7 +81,10 @@ const PERSONAL_PROFILE_FIELDS = [ 'dateFormat', 'timeFormat', 'appearance', - 'cvd' + 'cvd', + 'reduceMotion', + 'underlineLinks', + 'contentTextSize' ] as const /** @@ -268,7 +271,7 @@ async function routes(app: FastifyInstance) { schema: { summary: "Update the logged in user's own profile", 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'], body: { $ref: 'UserProfileUpdate#' @@ -311,7 +314,7 @@ async function routes(app: FastifyInstance) { const patch: UserProfilePatch = {} for (const key of [...IDENTITY_PROFILE_FIELDS, ...PERSONAL_PROFILE_FIELDS] as const) { if (req.body[key] !== undefined) { - patch[key] = req.body[key] + ;(patch as Record)[key] = req.body[key] } } if (!WIKI.models.authentication.isProfileEditingAllowed()) { @@ -344,7 +347,10 @@ async function routes(app: FastifyInstance) { dateFormat: profile.dateFormat, timeFormat: profile.timeFormat, 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 diff --git a/backend/locales/en.json b/backend/locales/en.json index af98c3fb1..8aac485bd 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -1238,6 +1238,7 @@ "admin.theme.codeBlocksAppearance": "Code Blocks Appearance", "admin.theme.codeBlocksAppearanceHint": "The color theme used to display code blocks on pages.", "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.contentFontHint": "The font used specifically for page content.", "admin.theme.contentWidth": "Content Width", @@ -1256,15 +1257,13 @@ "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.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.iconsetHint": "Set of icons to use for the sidebar navigation.", "admin.theme.layout": "Layout", "admin.theme.options": "Theme Options", "admin.theme.primaryColor": "Primary Color", "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.saveSuccess": "Theme configuration saved successfully!", "admin.theme.secondaryColor": "Secondary Color", @@ -1272,7 +1271,7 @@ "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.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.sidebarPositionHint": "On which side should the main site navigation sidebar be displayed.", "admin.theme.siteTheme": "Site Theme", @@ -2619,7 +2618,9 @@ "fileman.detailsDeletedAt": "Deleted", "fileman.detailsDeletedBy": "Deleted By", "fileman.detailsPageCreated": "Created", + "fileman.detailsPageDescription": "Description", "fileman.detailsPageEditor": "Editor", + "fileman.detailsPageTags": "Tags", "fileman.detailsPageType": "Type", "fileman.detailsPageUpdated": "Last Updated", "fileman.detailsPath": "Path", @@ -2629,9 +2630,11 @@ "fileman.epsFileType": "EPS Image", "fileman.excalidrawPageType": "Drawing", "fileman.exeFileType": "Windows Executable", + "fileman.fileCount": "1 file | {count} files", "fileman.flacFileType": "FLAC Audio File", "fileman.folderChildrenCount": "Empty folder | 1 child | {count} children", "fileman.folderColor": "Set Folder Color", + "fileman.folderCount": "1 folder | {count} folders", "fileman.folderCreate": "New Folder", "fileman.folderDuplicate": "Duplicate Folder To...", "fileman.folderDuplicating": "Duplicating folder...", @@ -2710,6 +2713,7 @@ "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.restoreSuccess": "Page restored successfully.", + "fileman.rightClickHint": "Right-click an item to see its actions.", "fileman.searchFolder": "Search folder...", "fileman.showFolders": "Show Folders", "fileman.svgFileType": "Scalable Vector Graphic", @@ -2984,6 +2988,11 @@ "profile.avatarUploadInvalidType": "Must be a PNG, JPEG, WebP or GIF image.", "profile.avatarUploadSuccess": "Profile picture uploaded successfully.", "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.cvdDeuteranopia": "Deuteranopia", "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.pronouns": "Pronouns", "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.saveFailed": "Failed to save profile changes.", "profile.saveSuccess": "Profile saved successfully.", @@ -3053,6 +3064,8 @@ "profile.timezone": "Timezone", "profile.timezoneHint": "Set your timezone to display local time correctly.", "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.viewPublicProfile": "View Public Profile", "renderPageDialog.loading": "Queueing page render...", diff --git a/backend/models/users.ts b/backend/models/users.ts index 87a291c68..ea7af6bbc 100644 --- a/backend/models/users.ts +++ b/backend/models/users.ts @@ -130,6 +130,12 @@ export interface UserProfile { timeFormat: string appearance: 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 appearance?: string cvd?: string + reduceMotion?: boolean + underlineLinks?: boolean + contentTextSize?: string } /** The `meta` keys the profile owns, and the `prefs` keys it owns. */ @@ -176,7 +185,10 @@ const profilePrefsKeys = [ 'dateFormat', 'timeFormat', 'appearance', - 'cvd' + 'cvd', + 'reduceMotion', + 'underlineLinks', + 'contentTextSize' ] as const /** @@ -578,7 +590,10 @@ class Users { dateFormat: WIKI.config.userDefaults?.dateFormat ?? 'YYYY-MM-DD', timeFormat: WIKI.config.userDefaults?.timeFormat ?? '12h', appearance: 'site', - cvd: 'none' + cvd: 'none', + reduceMotion: false, + underlineLinks: false, + contentTextSize: 'default' } }) .returning({ id: usersTable.id }) @@ -653,7 +668,10 @@ class Users { dateFormat: prefs.dateFormat ?? '', timeFormat: prefs.timeFormat ?? '12h', 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', timeFormat: '12h', appearance: 'site', - cvd: 'none' + cvd: 'none', + reduceMotion: false, + underlineLinks: false, + contentTextSize: 'default' } }, { @@ -1429,7 +1450,10 @@ class Users { dateFormat: 'YYYY-MM-DD', timeFormat: '12h', appearance: 'site', - cvd: 'none' + cvd: 'none', + reduceMotion: false, + underlineLinks: false, + contentTextSize: 'default' } } ]) @@ -2485,7 +2509,10 @@ class Users { dateFormat: user.prefs?.dateFormat, timeFormat: user.prefs?.timeFormat, 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))) // -> Group ids as well as their permissions, since navigation items are limited per group diff --git a/backend/types/fastify.d.ts b/backend/types/fastify.d.ts index 41a9a13bf..1a4a076ba 100644 --- a/backend/types/fastify.d.ts +++ b/backend/types/fastify.d.ts @@ -33,6 +33,9 @@ declare module 'fastify' { timeFormat?: string appearance?: string cvd?: string + reduceMotion?: boolean + underlineLinks?: boolean + contentTextSize?: string } /** Flattened, de-duplicated permissions of every group the user belongs to. */ permissions?: string[] diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c5cc74d39..a9b062116 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -62,6 +62,7 @@ const i18n = useI18n({ useScope: 'global' }) // ROUTER const router = useRouter() +const route = useRoute() // STATE @@ -91,6 +92,16 @@ watch( watch(() => commonStore.locale, applyLocale) +watch(isAdminArea, () => { + applyTheme() +}) + +watch( + () => [userStore.reduceMotion, userStore.underlineLinks, userStore.contentTextSize], + applyAccessibility, + { immediate: true } +) + // LOCALE async function applyLocale(locale) { @@ -115,8 +126,41 @@ async function applyLocale(locale) { document.documentElement.lang = locale } +// ACCESSIBILITY + +/* + The reader's own accessibility settings (Profile -> Info), as classes on -- 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 +/* + 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() { // -> Dark Mode if (userStore.appearance === 'site') { @@ -126,9 +170,16 @@ async function applyTheme() { } // -> CSS Vars - setCssVar('primary', userStore.getAccessibleColor('primary', siteStore.theme.colorPrimary)) - setCssVar('secondary', userStore.getAccessibleColor('secondary', siteStore.theme.colorSecondary)) - setCssVar('accent', userStore.getAccessibleColor('accent', siteStore.theme.colorAccent)) + const brand = isAdminArea() + ? ADMIN_COLORS + : { + 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('sidebar', userStore.getAccessibleColor('sidebar', siteStore.theme.colorSidebar)) setCssVar('positive', userStore.getAccessibleColor('positive', '#02C39A')) diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index 4c510a84c..2d03a581b 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -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. - 284 icons. + 285 icons. */ export const BUNDLED_ICONS = { "la:angle-down": {"body":"","width":32,"height":32}, @@ -107,6 +107,7 @@ export const BUNDLED_ICONS = { "la:microchip": {"body":"","width":32,"height":32}, "la:minus": {"body":"","width":32,"height":32}, "la:mountain": {"body":"","width":32,"height":32}, + "la:mouse-pointer": {"body":"","width":32,"height":32}, "la:newspaper": {"body":"","width":32,"height":32}, "la:otter": {"body":"","width":32,"height":32}, "la:paper-plane": {"body":"","width":32,"height":32}, diff --git a/frontend/src/components/CollabPresence.vue b/frontend/src/components/CollabPresence.vue index afb0de888..0745adc1e 100644 --- a/frontend/src/components/CollabPresence.vue +++ b/frontend/src/components/CollabPresence.vue @@ -213,4 +213,10 @@ function initials(name) { 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; +} diff --git a/frontend/src/components/EditorAsciidoc.vue b/frontend/src/components/EditorAsciidoc.vue index fc9aa4a0d..59ced8911 100644 --- a/frontend/src/components/EditorAsciidoc.vue +++ b/frontend/src/components/EditorAsciidoc.vue @@ -327,6 +327,7 @@ import { usePageStore } from '@/stores/page' import { useSiteStore } from '@/stores/site' import { useUserStore } from '@/stores/user' +import { scrollBehavior } from '@/helpers/motion' import { enhanceRenderedContent } from '@/helpers/renderedContent' import { debounce } from 'es-toolkit/function' @@ -1123,7 +1124,7 @@ function scrollPreviewTo(container, el) { const offset = el.getBoundingClientRect().top - container.getBoundingClientRect().top container.scrollTo({ top: container.scrollTop + offset - container.clientHeight * PREVIEW_CONTEXT_ABOVE, - behavior: 'smooth' + behavior: scrollBehavior() }) } @@ -1528,7 +1529,7 @@ onMounted(async () => { return } if (currentLine < 3) { - container.scrollTo({ top: 0, behavior: 'smooth' }) + container.scrollTo({ top: 0, behavior: scrollBehavior() }) return } const anchor = previewAnchorFor(container, currentLine) diff --git a/frontend/src/components/EditorMarkdown.vue b/frontend/src/components/EditorMarkdown.vue index bbc43dd00..1cbd8d1cf 100644 --- a/frontend/src/components/EditorMarkdown.vue +++ b/frontend/src/components/EditorMarkdown.vue @@ -356,6 +356,7 @@ import { writeBlockContent } from '@/helpers/markdownBlocks' import { findEditableTables } from '@/helpers/markdownTable' +import { scrollBehavior } from '@/helpers/motion' import EditorCodeBlockMenu from '@/components/EditorCodeBlockMenu.vue' import EditorEmojiMenu from '@/components/EditorEmojiMenu.vue' @@ -1301,7 +1302,7 @@ function scrollPreviewTo(container, el) { const offset = el.getBoundingClientRect().top - container.getBoundingClientRect().top container.scrollTo({ top: container.scrollTop + offset - container.clientHeight * PREVIEW_CONTEXT_ABOVE, - behavior: 'smooth' + behavior: scrollBehavior() }) } @@ -1774,7 +1775,7 @@ onMounted(async () => { return } if (currentLine < 3) { - container.scrollTo({ top: 0, behavior: 'smooth' }) + container.scrollTo({ top: 0, behavior: scrollBehavior() }) return } const anchor = previewAnchorFor(container, currentLine) diff --git a/frontend/src/components/FileManager.vue b/frontend/src/components/FileManager.vue index 7e6d649f8..a28cdd41c 100644 --- a/frontend/src/components/FileManager.vue +++ b/frontend/src/components/FileManager.vue @@ -149,56 +149,85 @@ - -
-