From 25be4957f98803cc50407d982a5f12de5cd0848c Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 28 Apr 2026 11:43:34 -0500 Subject: [PATCH 1/3] feat(a11y): add high-contrast theme alongside light/dark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the dark-mode toggle in the side menu with a 3-way segmented picker (Light / Dark / High Contrast) and adds an HC palette targeting WCAG 2.2 AAA — pure #000/#fff with a yellow primary, bold borders, and a 3px focus ring on interactive elements. Storage migrates the legacy darkTheme boolean to the new theme key on first read. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/app.component.html | 29 ++- src/app/app.component.scss | 36 ++++ src/app/app.component.ts | 13 +- .../pages/about-pycon/about-pycon.page.scss | 5 + .../pages/room-detail/room-detail.page.scss | 16 ++ src/app/pages/schedule/schedule.scss | 4 + .../pages/session-detail/session-detail.scss | 15 ++ src/app/providers/user-data.ts | 33 +++- src/theme/variables.scss | 180 ++++++++++++++++++ 9 files changed, 310 insertions(+), 21 deletions(-) diff --git a/src/app/app.component.html b/src/app/app.component.html index c4ea69cd..69c3374f 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,4 +1,4 @@ - + @@ -189,10 +189,29 @@

Install Latest Update

- - - Dark Mode - + + + Theme + + + + + + Light + + + + Dark + + + + HC + + diff --git a/src/app/app.component.scss b/src/app/app.component.scss index 4ac9a5ab..548e8c95 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -90,6 +90,42 @@ ion-item.indent { padding-left: 1.25em; } +/* + * Theme picker — segmented control sits flush in the side menu. + * Container item drops native padding so the segment uses the full menu width. + */ +ion-menu ion-item.theme-picker-segment-item { + --padding-start: 12px; + --padding-end: 12px; + --inner-padding-end: 0; + --min-height: 44px; + margin-bottom: 8px; +} + +ion-menu .theme-picker-segment { + width: 100%; + --background: var(--ion-color-step-100, rgba(0, 0, 0, 0.04)); + + ion-segment-button { + --indicator-height: 2px; + min-height: 40px; + min-width: 0; + flex: 1 1 0; + text-transform: none; + font-size: 12px; + + ion-icon { + font-size: 18px; + margin-bottom: 2px; + } + + ion-label { + font-size: 11px; + letter-spacing: 0.02em; + } + } +} + ion-accordion div[slot="content"] ion-item { --padding-start: 40px; } diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 317bc081..0bf3ea6c 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -7,7 +7,7 @@ import { PushNotifications, PushNotificationSchema } from '@capacitor/push-notif import { Storage } from '@ionic/storage-angular'; -import { UserData } from './providers/user-data'; +import { UserData, ThemeMode } from './providers/user-data'; import { ConferenceData } from './providers/conference-data'; import { LiveUpdateService } from './providers/live-update.service'; import { environment } from '../environments/environment'; @@ -61,7 +61,7 @@ export class AppComponent implements OnInit { ] nickname = null; loggedIn = false; - dark = false; + theme: ThemeMode = 'light'; updateAvailable: any = null; @@ -207,13 +207,14 @@ export class AppComponent implements OnInit { } loadTheme() { - this.userData.getDarkTheme().then(dark => { - this.dark = dark; + this.userData.getTheme().then(theme => { + this.theme = theme; }); } - toggleDarkTheme() { - this.userData.toggleDarkTheme(); + setTheme(theme: ThemeMode) { + this.theme = theme; + this.userData.setTheme(theme); } openUrl(url: string) { diff --git a/src/app/pages/about-pycon/about-pycon.page.scss b/src/app/pages/about-pycon/about-pycon.page.scss index eb8174aa..db3a2203 100644 --- a/src/app/pages/about-pycon/about-pycon.page.scss +++ b/src/app/pages/about-pycon/about-pycon.page.scss @@ -75,6 +75,11 @@ ion-title { --background: #1e1e1e; } +:host-context(.high-contrast-theme) .about-card { + --background: #000000; + border: 2px solid #ffffff; +} + .dev-accordion { margin: 0 16px 16px; } diff --git a/src/app/pages/room-detail/room-detail.page.scss b/src/app/pages/room-detail/room-detail.page.scss index 637d36d8..cd21c001 100644 --- a/src/app/pages/room-detail/room-detail.page.scss +++ b/src/app/pages/room-detail/room-detail.page.scss @@ -98,6 +98,14 @@ ion-title { box-shadow: 0 2px 6px rgba(221, 4, 210, 0.30); } +:host-context(.high-contrast-theme) .day-section .day-header { + --pycon-accent: #ffff00; + background: #000000; + color: #ffff00; + border: 2px solid #ffff00; + box-shadow: none; +} + .session-item { --padding-start: 16px; --padding-end: 8px; @@ -153,3 +161,11 @@ ion-title { --background: rgba(221, 4, 210, 0.10); box-shadow: inset 3px 0 0 #DD04D2; } + +:host-context(.high-contrast-theme) .session-item.session-item-highlight { + --background: #000000; + box-shadow: inset 4px 0 0 #ffff00; + outline: 2px solid #ffff00; + outline-offset: -2px; + animation: none; +} diff --git a/src/app/pages/schedule/schedule.scss b/src/app/pages/schedule/schedule.scss index eeb7c30c..0d446b04 100644 --- a/src/app/pages/schedule/schedule.scss +++ b/src/app/pages/schedule/schedule.scss @@ -44,6 +44,10 @@ $tracks: ( --pycon-accent: #DD04D2; } +:host-context(.high-contrast-theme) { + --pycon-accent: #ffff00; +} + .schedule-toolbar { --padding-start: 0; --padding-end: 0; diff --git a/src/app/pages/session-detail/session-detail.scss b/src/app/pages/session-detail/session-detail.scss index 6d945b79..81e27083 100644 --- a/src/app/pages/session-detail/session-detail.scss +++ b/src/app/pages/session-detail/session-detail.scss @@ -139,6 +139,12 @@ ion-toolbar ion-button { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); } +:host-context(.high-contrast-theme) .session-meta-card { + background: #000000; + border: 2px solid #ffffff; + box-shadow: none; +} + .meta-row { display: flex; align-items: center; @@ -175,6 +181,15 @@ ion-toolbar ion-button { color: #8b8fd4; } +:host-context(.high-contrast-theme) .meta-row .room-link { + color: #ffff00; + text-decoration-thickness: 2px; +} + +:host-context(.high-contrast-theme) .meta-row ion-icon { + color: #ffff00; +} + /* * Body content below the card */ diff --git a/src/app/providers/user-data.ts b/src/app/providers/user-data.ts index da8bd5b2..24edf4a1 100644 --- a/src/app/providers/user-data.ts +++ b/src/app/providers/user-data.ts @@ -14,6 +14,8 @@ export function isCustomScheduleFilter(excluded: ReadonlyArray): boolean return !excluded.every(track => defaults.has(track)); } +export type ThemeMode = 'light' | 'dark' | 'high-contrast'; +export const THEME_MODES: ThemeMode[] = ['light', 'dark', 'high-contrast']; @Injectable({ providedIn: 'root' @@ -77,16 +79,27 @@ export class UserData { }) } - // Get current theme from storage - getDarkTheme() { - return this.storage.get('darkTheme'); - } - - // Toggle Dark Theme. Sets inverted value to storage - toggleDarkTheme() { - this.getDarkTheme().then((darkTheme) => { - this.storage.set('darkTheme', !darkTheme); - }); + // Resolve the active theme. Migrates legacy `darkTheme` boolean callers + // (anything saved before the tri-state picker shipped) to the new key. + async getTheme(): Promise { + const stored = await this.storage.get('theme'); + if (stored && THEME_MODES.indexOf(stored) !== -1) { + return stored; + } + const legacyDark = await this.storage.get('darkTheme'); + if (legacyDark === true) { + await this.storage.set('theme', 'dark'); + await this.storage.remove('darkTheme'); + return 'dark'; + } + if (legacyDark === false) { + await this.storage.remove('darkTheme'); + } + return 'light'; + } + + setTheme(theme: ThemeMode): Promise { + return this.storage.set('theme', theme); } hasFavorite(sessionId: string): boolean { diff --git a/src/theme/variables.scss b/src/theme/variables.scss index 7bb20392..0d204ddb 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -416,3 +416,183 @@ --ion-tab-bar-background: #1f1f1f; } + +/* + * High Contrast Theme (a11y) + * ---------------------------------------------------------------------------- + * Targets WCAG 2.2 AAA (7:1+) for body text and bold UI affordances: + * - Pure #000 background / #fff text -> 21:1 contrast + * - #ffff00 yellow primary on black -> 19.56:1 + * - #00ffff cyan secondary on black -> 16.75:1 + * - #00ff00 green success on black -> 15.30:1 + * - #ff5b5b red danger on black -> 7.46:1 + * Borders and focus rings are intentionally chunky/yellow so interactive + * elements remain locatable for users with low vision. + */ +.high-contrast-theme { + --ion-color-primary: #ffff00; + --ion-color-primary-rgb: 255,255,0; + --ion-color-primary-contrast: #000000; + --ion-color-primary-contrast-rgb: 0,0,0; + --ion-color-primary-shade: #e0e000; + --ion-color-primary-tint: #ffff33; + + --ion-color-secondary: #00ffff; + --ion-color-secondary-rgb: 0,255,255; + --ion-color-secondary-contrast: #000000; + --ion-color-secondary-contrast-rgb: 0,0,0; + --ion-color-secondary-shade: #00e0e0; + --ion-color-secondary-tint: #33ffff; + + --ion-color-tertiary: #ff66ff; + --ion-color-tertiary-rgb: 255,102,255; + --ion-color-tertiary-contrast: #000000; + --ion-color-tertiary-contrast-rgb: 0,0,0; + --ion-color-tertiary-shade: #e05ae0; + --ion-color-tertiary-tint: #ff7aff; + + --ion-color-success: #00ff00; + --ion-color-success-rgb: 0,255,0; + --ion-color-success-contrast: #000000; + --ion-color-success-contrast-rgb: 0,0,0; + --ion-color-success-shade: #00e000; + --ion-color-success-tint: #33ff33; + + --ion-color-warning: #ffcc00; + --ion-color-warning-rgb: 255,204,0; + --ion-color-warning-contrast: #000000; + --ion-color-warning-contrast-rgb: 0,0,0; + --ion-color-warning-shade: #e0b300; + --ion-color-warning-tint: #ffd633; + + --ion-color-danger: #ff5b5b; + --ion-color-danger-rgb: 255,91,91; + --ion-color-danger-contrast: #000000; + --ion-color-danger-contrast-rgb: 0,0,0; + --ion-color-danger-shade: #e05050; + --ion-color-danger-tint: #ff7575; + + --ion-color-dark: #ffffff; + --ion-color-dark-rgb: 255,255,255; + --ion-color-dark-contrast: #000000; + --ion-color-dark-contrast-rgb: 0,0,0; + --ion-color-dark-shade: #e0e0e0; + --ion-color-dark-tint: #ffffff; + + --ion-color-medium: #d0d0d0; + --ion-color-medium-rgb: 208,208,208; + --ion-color-medium-contrast: #000000; + --ion-color-medium-contrast-rgb: 0,0,0; + --ion-color-medium-shade: #b8b8b8; + --ion-color-medium-tint: #dddddd; + + --ion-color-light: #000000; + --ion-color-light-rgb: 0,0,0; + --ion-color-light-contrast: #ffffff; + --ion-color-light-contrast-rgb: 255,255,255; + --ion-color-light-shade: #000000; + --ion-color-light-tint: #1a1a1a; + + --ion-color-favorite: #ffff00; + --ion-color-favorite-rgb: 255,255,0; + --ion-color-favorite-contrast: #000000; + --ion-color-favorite-contrast-rgb: 0,0,0; + --ion-color-favorite-shade: #e0e000; + --ion-color-favorite-tint: #ffff33; + + --ion-background-color: #000000; + --ion-background-color-rgb: 0,0,0; + + --ion-text-color: #ffffff; + --ion-text-color-rgb: 255,255,255; + + --ion-border-color: #ffffff; + + --ion-color-step-50: #0d0d0d; + --ion-color-step-100: #1a1a1a; + --ion-color-step-150: #262626; + --ion-color-step-200: #333333; + --ion-color-step-250: #404040; + --ion-color-step-300: #4d4d4d; + --ion-color-step-350: #595959; + --ion-color-step-400: #666666; + --ion-color-step-450: #737373; + --ion-color-step-500: #d0d0d0; + --ion-color-step-550: #d6d6d6; + --ion-color-step-600: #dcdcdc; + --ion-color-step-650: #e2e2e2; + --ion-color-step-700: #e8e8e8; + --ion-color-step-750: #ededed; + --ion-color-step-800: #f2f2f2; + --ion-color-step-850: #f7f7f7; + --ion-color-step-900: #fbfbfb; + --ion-color-step-950: #ffffff; + + --ion-item-background: #000000; + --ion-item-color: #ffffff; + --ion-item-border-color: #ffffff; + + --ion-toolbar-background: #000000; + --ion-toolbar-color: #ffffff; + --ion-toolbar-border-color: #ffffff; + + --ion-tab-bar-background: #000000; + --ion-tab-bar-color: #ffffff; + --ion-tab-bar-color-selected: #ffff00; + --ion-tab-bar-border-color: #ffffff; + + --ion-card-background: #000000; + --ion-card-color: #ffffff; +} + +.high-contrast-theme.ios, +.high-contrast-theme.md { + --ion-background-color: #000000; + --ion-background-color-rgb: 0,0,0; + --ion-text-color: #ffffff; + --ion-text-color-rgb: 255,255,255; + --ion-toolbar-background: #000000; + --ion-item-background: #000000; + --ion-tab-bar-background: #000000; +} + +.high-contrast-theme { + ion-toolbar { + --border-width: 0 0 2px 0; + --border-color: #ffffff; + } + + ion-tab-bar { + border-top: 2px solid #ffffff; + } + + ion-card { + border: 2px solid #ffffff; + box-shadow: none; + } + + ion-item { + --border-color: #ffffff; + } + + ion-button { + --border-width: 2px; + --border-style: solid; + --border-color: currentColor; + } + + ion-segment-button { + --border-color: #ffffff; + } + + a, + button:focus-visible, + ion-button:focus-visible, + ion-item:focus-visible, + ion-tab-button:focus-visible, + ion-segment-button:focus-visible, + [tabindex]:focus-visible { + outline: 3px solid #ffff00; + outline-offset: 2px; + } +} From 48aebd3858775e1e716febac1729b6cc46870d0e Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 28 Apr 2026 14:24:23 -0500 Subject: [PATCH 2/3] feat(a11y): refine HC palettes, add HC light variant, and fix toolbar contrast Iterative pass after the initial HC theme work: * Replace neon HC palette with Apple-system colors (#ffd60a yellow, #5ac8fa cyan, #c084ff purple). Inline contrast ratios documented at every color rule; both HC variants beat WCAG AAA for body text. * Split HC into `high-contrast-light` (cream + deep purple #4a0072) and `high-contrast-dark` (deep ink + warm yellow). Storage migrates the legacy single `'high-contrast'` value forward. * Swap the side-menu theme picker from a 3-button segment to an ion-select with action-sheet interface so all four full-text labels fit without truncation. * Drop the `[color]="session.color"` row tinting and the per-track `border-left` side-stripe from the schedule list; the track-badge text already names the track. Closes WCAG 1.4.1 (use of color), the impeccable side-stripe ban, and a sub-3:1 border-color issue in HC light. * Repaint the seven track-badge fills that failed WCAG AA: tutorial, lightning-talks, and charla darkened for white text; security, ai, poster, and sponsor flipped to dark text on their original bg. * Add a global `prefers-reduced-motion: reduce` block in global.scss to ramp every animation and transition to ~0ms; closes the two infinite pin-pulse animations on the maps. * Add a skip-to-content link as the first focusable child of ion-app; ion-router-outlet picks up tabindex=-1 so focus actually transfers. * Fix OS-coupled image inversion in social-media.page.scss to follow the in-app dark themes instead of `prefers-color-scheme`. * Replace hardcoded HC surface colors in page overrides with var(--ion-card-background) so the rules collapse to one rule per surface that themes correctly per variant. * Add a single global rule that forces white on every menu/back button in a toolbar, except those that explicitly opt into Ionic's color system via [color="..."]. Fixes near-black hamburger/back icons on the ~20 pages with branded purple gradient headers in light theme. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/app.component.html | 36 +- src/app/app.component.scss | 63 ++- .../pages/about-pycon/about-pycon.page.scss | 4 +- .../pages/room-detail/room-detail.page.scss | 14 +- .../schedule-list/schedule-list.page.scss | 24 +- src/app/pages/schedule/schedule.html | 2 +- src/app/pages/schedule/schedule.scss | 35 +- .../pages/session-detail/session-detail.scss | 8 +- .../pages/social-media/social-media.page.scss | 9 +- src/app/providers/user-data.ts | 22 +- src/global.scss | 81 +++- src/theme/variables.scss | 408 +++++++++++------- 12 files changed, 431 insertions(+), 275 deletions(-) diff --git a/src/app/app.component.html b/src/app/app.component.html index 69c3374f..59ab0f36 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,4 +1,9 @@ - + + @@ -191,34 +196,25 @@

Install Latest Update

- Theme - - - - - - Light - - - - Dark - - - - HC - - + Light + Dark + High Contrast (Light) + High Contrast (Dark) +
- +
diff --git a/src/app/app.component.scss b/src/app/app.component.scss index 548e8c95..7992f928 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -1,3 +1,30 @@ +/* + * Skip-to-content link for keyboard and screen-reader users. Hidden off-canvas + * until focused, then anchors to top-left as the first interactive control on + * every page. Targets the router outlet's id so focus actually transfers. + */ +.skip-link { + position: absolute; + top: -100px; + left: 0; + z-index: 10000; + padding: 12px 20px; + background: var(--ion-color-primary); + color: var(--ion-color-primary-contrast); + font-weight: 600; + font-size: 0.9rem; + text-decoration: none; + border-radius: 0 0 8px 0; + transition: top 180ms ease-out; +} + +.skip-link:focus, +.skip-link:focus-visible { + top: 0; + outline: 3px solid var(--ion-color-primary); + outline-offset: 2px; +} + ion-menu ion-content { --padding-top: 20px; --padding-bottom: 20px; @@ -91,39 +118,11 @@ ion-item.indent { } /* - * Theme picker — segmented control sits flush in the side menu. - * Container item drops native padding so the segment uses the full menu width. + * Theme picker — ion-select popover keeps the four theme options compact + * in the side menu and leaves room for descriptive labels. */ -ion-menu ion-item.theme-picker-segment-item { - --padding-start: 12px; - --padding-end: 12px; - --inner-padding-end: 0; - --min-height: 44px; - margin-bottom: 8px; -} - -ion-menu .theme-picker-segment { - width: 100%; - --background: var(--ion-color-step-100, rgba(0, 0, 0, 0.04)); - - ion-segment-button { - --indicator-height: 2px; - min-height: 40px; - min-width: 0; - flex: 1 1 0; - text-transform: none; - font-size: 12px; - - ion-icon { - font-size: 18px; - margin-bottom: 2px; - } - - ion-label { - font-size: 11px; - letter-spacing: 0.02em; - } - } +ion-menu ion-item.theme-picker-item ion-select { + font-weight: 500; } ion-accordion div[slot="content"] ion-item { diff --git a/src/app/pages/about-pycon/about-pycon.page.scss b/src/app/pages/about-pycon/about-pycon.page.scss index db3a2203..e706f1bc 100644 --- a/src/app/pages/about-pycon/about-pycon.page.scss +++ b/src/app/pages/about-pycon/about-pycon.page.scss @@ -76,8 +76,8 @@ ion-title { } :host-context(.high-contrast-theme) .about-card { - --background: #000000; - border: 2px solid #ffffff; + --background: var(--ion-card-background); + border: 1px solid var(--ion-border-color); } .dev-accordion { diff --git a/src/app/pages/room-detail/room-detail.page.scss b/src/app/pages/room-detail/room-detail.page.scss index cd21c001..2918e7a4 100644 --- a/src/app/pages/room-detail/room-detail.page.scss +++ b/src/app/pages/room-detail/room-detail.page.scss @@ -99,10 +99,10 @@ ion-title { } :host-context(.high-contrast-theme) .day-section .day-header { - --pycon-accent: #ffff00; - background: #000000; - color: #ffff00; - border: 2px solid #ffff00; + --pycon-accent: var(--ion-color-primary); + background: var(--ion-card-background); + color: var(--ion-color-primary); + border: 1px solid var(--ion-border-color); box-shadow: none; } @@ -163,9 +163,9 @@ ion-title { } :host-context(.high-contrast-theme) .session-item.session-item-highlight { - --background: #000000; - box-shadow: inset 4px 0 0 #ffff00; - outline: 2px solid #ffff00; + --background: var(--ion-item-background); + box-shadow: inset 4px 0 0 var(--ion-color-primary); + outline: 2px solid var(--ion-color-primary); outline-offset: -2px; animation: none; } diff --git a/src/app/pages/schedule-list/schedule-list.page.scss b/src/app/pages/schedule-list/schedule-list.page.scss index 9193d9d0..ca3258ab 100644 --- a/src/app/pages/schedule-list/schedule-list.page.scss +++ b/src/app/pages/schedule-list/schedule-list.page.scss @@ -86,27 +86,9 @@ ion-title { margin-right: 12px; } -$tracks: ( - talk: #5833E9, - tutorial: #DD04D2, - keynote: #680579, - plenary: #630675, - break: #3A3A3A, - lightning-talks: #C05CA0, - security: #F19C0B, - ai: #10B57F, - charla: #527CB2, - poster: #D47454, - sponsor\ presentation: #FFD779, - open\ space: #6FCF97, -); - -@each $track, $color in $tracks { - ion-item[track='#{$track}'] ion-label { - border-left: 3px solid $color; - padding-left: 10px; - } -} +// Track membership is communicated by the .track-badge text inside each +// row (see src/global.scss). The side-stripe accent was removed for the +// same reasons noted in src/app/pages/schedule/schedule.scss. .session-list-item { --padding-top: 10px; diff --git a/src/app/pages/schedule/schedule.html b/src/app/pages/schedule/schedule.html index 94653e29..54bea26d 100644 --- a/src/app/pages/schedule/schedule.html +++ b/src/app/pages/schedule/schedule.html @@ -81,7 +81,7 @@

- +

diff --git a/src/app/pages/schedule/schedule.scss b/src/app/pages/schedule/schedule.scss index 0d446b04..e209e7f9 100644 --- a/src/app/pages/schedule/schedule.scss +++ b/src/app/pages/schedule/schedule.scss @@ -14,27 +14,12 @@ ion-fab-button { --background-activated: var(--ion-color-step-250, #d9d9d9); } -$tracks: ( - talk: #5833E9, - tutorial: #DD04D2, - keynote: #680579, - plenary: #630675, - break: #3A3A3A, - lightning-talks: #C05CA0, - security: #F19C0B, - ai: #10B57F, - charla: #527CB2, - poster: #D47454, - sponsor\ presentation: #FFD779, - open\ space: #6FCF97, -); - -@each $track, $color in $tracks { - ion-item-sliding[track='#{$track}'] ion-label { - border-left: 3px solid $color; - padding-left: 10px; - } -} +// Track membership is communicated by the .track-badge text inside each +// row (see src/global.scss). The previous side-stripe accent was removed +// because (a) it violated the side-stripe ban for >1px colored borders, +// (b) several of its colors fell below 3:1 against the cream HC-Light +// background, and (c) duplicating track info via a colored border on top +// of a colored badge was redundant. :host { --pycon-accent: #680579; @@ -44,8 +29,12 @@ $tracks: ( --pycon-accent: #DD04D2; } -:host-context(.high-contrast-theme) { - --pycon-accent: #ffff00; +:host-context(.high-contrast-light-theme) { + --pycon-accent: #4a0072; +} + +:host-context(.high-contrast-dark-theme) { + --pycon-accent: #ffd60a; } .schedule-toolbar { diff --git a/src/app/pages/session-detail/session-detail.scss b/src/app/pages/session-detail/session-detail.scss index 81e27083..985acfab 100644 --- a/src/app/pages/session-detail/session-detail.scss +++ b/src/app/pages/session-detail/session-detail.scss @@ -140,8 +140,8 @@ ion-toolbar ion-button { } :host-context(.high-contrast-theme) .session-meta-card { - background: #000000; - border: 2px solid #ffffff; + background: var(--ion-card-background); + border: 1px solid var(--ion-border-color); box-shadow: none; } @@ -182,12 +182,12 @@ ion-toolbar ion-button { } :host-context(.high-contrast-theme) .meta-row .room-link { - color: #ffff00; + color: var(--ion-color-primary); text-decoration-thickness: 2px; } :host-context(.high-contrast-theme) .meta-row ion-icon { - color: #ffff00; + color: var(--ion-color-primary); } /* diff --git a/src/app/pages/social-media/social-media.page.scss b/src/app/pages/social-media/social-media.page.scss index 9d1b45e9..d2cf449f 100644 --- a/src/app/pages/social-media/social-media.page.scss +++ b/src/app/pages/social-media/social-media.page.scss @@ -74,9 +74,12 @@ ion-icon.social-icon.bluesky { color: #0085ff; } -@media (prefers-color-scheme: dark) { - // PNG/multicolor logos that should NOT be inverted in dark mode - // (ion-icon SVGs and the colored Bluesky icon already adapt via currentColor) +// PNG/multicolor logos that should be inverted on dark surfaces. Driven off +// the in-app theme classes rather than `prefers-color-scheme` so HC Light over +// an OS-dark preference still shows un-inverted logos on the cream background. +// (ion-icon SVGs and the colored Bluesky icon already adapt via currentColor.) +:host-context(.dark-theme), +:host-context(.high-contrast-dark-theme) { .social-icon.pypi { filter: none; } diff --git a/src/app/providers/user-data.ts b/src/app/providers/user-data.ts index 24edf4a1..1e078969 100644 --- a/src/app/providers/user-data.ts +++ b/src/app/providers/user-data.ts @@ -14,8 +14,17 @@ export function isCustomScheduleFilter(excluded: ReadonlyArray): boolean return !excluded.every(track => defaults.has(track)); } -export type ThemeMode = 'light' | 'dark' | 'high-contrast'; -export const THEME_MODES: ThemeMode[] = ['light', 'dark', 'high-contrast']; +export type ThemeMode = + | 'light' + | 'dark' + | 'high-contrast-light' + | 'high-contrast-dark'; +export const THEME_MODES: ThemeMode[] = [ + 'light', + 'dark', + 'high-contrast-light', + 'high-contrast-dark', +]; @Injectable({ providedIn: 'root' @@ -79,10 +88,15 @@ export class UserData { }) } - // Resolve the active theme. Migrates legacy `darkTheme` boolean callers - // (anything saved before the tri-state picker shipped) to the new key. + // Resolve the active theme. Migrates legacy storage shapes: + // darkTheme: true -> 'dark' (pre-tri-state picker) + // theme: 'high-contrast' -> 'high-contrast-dark' (pre-HC-light split) async getTheme(): Promise { const stored = await this.storage.get('theme'); + if (stored === 'high-contrast') { + await this.storage.set('theme', 'high-contrast-dark'); + return 'high-contrast-dark'; + } if (stored && THEME_MODES.indexOf(stored) !== -1) { return stored; } diff --git a/src/global.scss b/src/global.scss index 0d13e9cb..1a4634de 100644 --- a/src/global.scss +++ b/src/global.scss @@ -60,6 +60,44 @@ ion-content [innerHTML] { word-break: break-word; } +/* + * Reduced motion: respect users who request fewer animations at the OS level. + * Ramps every animation/transition to ~0ms instead of disabling outright so + * Ionic's gesture-driven UI (swipe-to-favorite, page transitions) still + * settles into final state cleanly. Two pin-pulse animations on the maps + * are infinite and would otherwise loop forever for users with vestibular + * sensitivity. + */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* + * Branded-header toolbar buttons. + * + * About 20 pages paint a custom gradient on ion-header and leave ion-toolbar + * with --background: transparent. Title text forces white via the page's own + * --color rule, but Ionic's platform-specific CSS for ion-menu-button and + * ion-back-button wins on specificity over the page's intended white, so the + * icons render in Ionic's default near-black in light theme. + * + * Force white on any toolbar button that hasn't explicitly opted into Ionic's + * color system via a [color="..."] attribute (e.g., the schedule's + * color="medium" hamburger and the login page's color="medium" back button + * keep their semantic Ionic colors). + */ +ion-header ion-toolbar ion-menu-button:not([color]), +ion-header ion-toolbar ion-back-button:not([color]) { + --color: #ffffff; +} + /* * Track badges — shared across schedule, speaker, and session pages */ @@ -74,18 +112,37 @@ ion-content [innerHTML] { color: #ffffff; background-color: var(--ion-color-medium, #92949c); - &[data-track='talk'] { background-color: #5833E9; } - &[data-track='tutorial'] { background-color: #DD04D2; } - &[data-track='keynote'] { background-color: #680579; } - &[data-track='plenary'] { background-color: #630675; } - &[data-track='break'] { background-color: #3A3A3A; } - &[data-track='lightning-talks'] { background-color: #C05CA0; } - &[data-track='security'] { background-color: #F19C0B; } - &[data-track='ai'] { background-color: #10B57F; } - &[data-track='charla'] { background-color: #527CB2; } - &[data-track='poster'] { background-color: #D47454; } - &[data-track='sponsor presentation'] { background-color: #B8860B; } - &[data-track='open space'] { background-color: #6FCF97; color: #1a1a1a; } + /* + * Per-track palette tuned for WCAG AA on the badge's text color. + * Bright fills (orange/green/coral/amber) carry dark text to preserve their + * "pop" character; saturated mids (magenta/pink/blue) were darkened so the + * default white text reaches 4.5:1+. Ratios noted alongside each rule. + */ + &[data-track='talk'] { background-color: #5833E9; } /* white 7.0:1 */ + &[data-track='tutorial'] { background-color: #A6049B; } /* white 6.7:1 */ + &[data-track='keynote'] { background-color: #680579; } /* white 13:1 */ + &[data-track='plenary'] { background-color: #630675; } /* white 13:1 */ + &[data-track='break'] { background-color: #3A3A3A; } /* white 12:1 */ + &[data-track='lightning-talks'] { background-color: #9C3F80; } /* white 6.1:1 */ + &[data-track='security'] { background-color: #F19C0B; color: #1a1a1a; } /* dark 7.9:1 */ + &[data-track='ai'] { background-color: #10B57F; color: #1a1a1a; } /* dark 6.7:1 */ + &[data-track='charla'] { background-color: #3D5F94; } /* white 6.6:1 */ + &[data-track='poster'] { background-color: #D47454; color: #1a1a1a; } /* dark 5.2:1 */ + &[data-track='sponsor presentation'] { background-color: #B8860B; color: #1a1a1a; } /* dark 5.1:1 */ + &[data-track='open space'] { background-color: #6FCF97; color: #1a1a1a; } /* dark 7.4:1 */ + + /* + * High Contrast: drop the colored fills and use a bordered text pill so + * badges stay legible regardless of track. Several of the tinted fills + * above (security, ai, charla, poster, sponsor) fail WCAG AA on white + * text, and HC users shouldn't have to rely on color to identify track. + */ + .high-contrast-theme &, + .high-contrast-theme &[data-track] { + background-color: transparent; + color: var(--ion-text-color); + border: 1.5px solid var(--ion-text-color); + } &.new-badge { background-color: #680579; diff --git a/src/theme/variables.scss b/src/theme/variables.scss index 0d204ddb..2f9a93e9 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -418,181 +418,297 @@ } /* - * High Contrast Theme (a11y) + * High Contrast Themes (a11y) * ---------------------------------------------------------------------------- - * Targets WCAG 2.2 AAA (7:1+) for body text and bold UI affordances: - * - Pure #000 background / #fff text -> 21:1 contrast - * - #ffff00 yellow primary on black -> 19.56:1 - * - #00ffff cyan secondary on black -> 16.75:1 - * - #00ff00 green success on black -> 15.30:1 - * - #ff5b5b red danger on black -> 7.46:1 - * Borders and focus rings are intentionally chunky/yellow so interactive - * elements remain locatable for users with low vision. + * Two variants — `.high-contrast-light-theme` and `.high-contrast-dark-theme` + * — both share the `.high-contrast-theme` class for cross-cutting affordances + * (visible focus ring, bold card/toolbar separators). + * + * Palettes use Apple system colors instead of pure neon CRT colors so the UI + * isn't physically uncomfortable to look at while still beating WCAG 2.2 AAA + * (7:1+) for body text. Spot-checked contrast ratios are noted inline. */ -.high-contrast-theme { - --ion-color-primary: #ffff00; - --ion-color-primary-rgb: 255,255,0; - --ion-color-primary-contrast: #000000; - --ion-color-primary-contrast-rgb: 0,0,0; - --ion-color-primary-shade: #e0e000; - --ion-color-primary-tint: #ffff33; - - --ion-color-secondary: #00ffff; - --ion-color-secondary-rgb: 0,255,255; - --ion-color-secondary-contrast: #000000; - --ion-color-secondary-contrast-rgb: 0,0,0; - --ion-color-secondary-shade: #00e0e0; - --ion-color-secondary-tint: #33ffff; - - --ion-color-tertiary: #ff66ff; - --ion-color-tertiary-rgb: 255,102,255; - --ion-color-tertiary-contrast: #000000; - --ion-color-tertiary-contrast-rgb: 0,0,0; - --ion-color-tertiary-shade: #e05ae0; - --ion-color-tertiary-tint: #ff7aff; - - --ion-color-success: #00ff00; - --ion-color-success-rgb: 0,255,0; - --ion-color-success-contrast: #000000; - --ion-color-success-contrast-rgb: 0,0,0; - --ion-color-success-shade: #00e000; - --ion-color-success-tint: #33ff33; - - --ion-color-warning: #ffcc00; - --ion-color-warning-rgb: 255,204,0; - --ion-color-warning-contrast: #000000; - --ion-color-warning-contrast-rgb: 0,0,0; - --ion-color-warning-shade: #e0b300; - --ion-color-warning-tint: #ffd633; - - --ion-color-danger: #ff5b5b; - --ion-color-danger-rgb: 255,91,91; - --ion-color-danger-contrast: #000000; - --ion-color-danger-contrast-rgb: 0,0,0; - --ion-color-danger-shade: #e05050; - --ion-color-danger-tint: #ff7575; - - --ion-color-dark: #ffffff; - --ion-color-dark-rgb: 255,255,255; - --ion-color-dark-contrast: #000000; - --ion-color-dark-contrast-rgb: 0,0,0; - --ion-color-dark-shade: #e0e0e0; - --ion-color-dark-tint: #ffffff; - - --ion-color-medium: #d0d0d0; - --ion-color-medium-rgb: 208,208,208; - --ion-color-medium-contrast: #000000; - --ion-color-medium-contrast-rgb: 0,0,0; - --ion-color-medium-shade: #b8b8b8; - --ion-color-medium-tint: #dddddd; - - --ion-color-light: #000000; - --ion-color-light-rgb: 0,0,0; - --ion-color-light-contrast: #ffffff; - --ion-color-light-contrast-rgb: 255,255,255; - --ion-color-light-shade: #000000; - --ion-color-light-tint: #1a1a1a; - --ion-color-favorite: #ffff00; - --ion-color-favorite-rgb: 255,255,0; - --ion-color-favorite-contrast: #000000; - --ion-color-favorite-contrast-rgb: 0,0,0; - --ion-color-favorite-shade: #e0e000; - --ion-color-favorite-tint: #ffff33; - - --ion-background-color: #000000; - --ion-background-color-rgb: 0,0,0; - - --ion-text-color: #ffffff; - --ion-text-color-rgb: 255,255,255; - - --ion-border-color: #ffffff; +/* HC Light: warm cream background, deep ink text, deep purple accent. */ +.high-contrast-light-theme { + /* primary #4a0072 on cream ~13.7:1 */ + --ion-color-primary: #4a0072; + --ion-color-primary-rgb: 74,0,114; + --ion-color-primary-contrast: #ffffff; + --ion-color-primary-contrast-rgb: 255,255,255; + --ion-color-primary-shade: #410064; + --ion-color-primary-tint: #5c1a80; - --ion-color-step-50: #0d0d0d; - --ion-color-step-100: #1a1a1a; - --ion-color-step-150: #262626; - --ion-color-step-200: #333333; - --ion-color-step-250: #404040; - --ion-color-step-300: #4d4d4d; - --ion-color-step-350: #595959; - --ion-color-step-400: #666666; - --ion-color-step-450: #737373; - --ion-color-step-500: #d0d0d0; - --ion-color-step-550: #d6d6d6; - --ion-color-step-600: #dcdcdc; - --ion-color-step-650: #e2e2e2; - --ion-color-step-700: #e8e8e8; - --ion-color-step-750: #ededed; - --ion-color-step-800: #f2f2f2; - --ion-color-step-850: #f7f7f7; - --ion-color-step-900: #fbfbfb; - --ion-color-step-950: #ffffff; + /* secondary #003a99 on cream ~10.8:1 */ + --ion-color-secondary: #003a99; + --ion-color-secondary-rgb: 0,58,153; + --ion-color-secondary-contrast: #ffffff; + --ion-color-secondary-contrast-rgb: 255,255,255; + --ion-color-secondary-shade: #00337f; + --ion-color-secondary-tint: #1a4ea3; - --ion-item-background: #000000; - --ion-item-color: #ffffff; - --ion-item-border-color: #ffffff; + /* tertiary #7a1a8a (deep magenta) on cream ~8.3:1 */ + --ion-color-tertiary: #7a1a8a; + --ion-color-tertiary-rgb: 122,26,138; + --ion-color-tertiary-contrast: #ffffff; + --ion-color-tertiary-contrast-rgb: 255,255,255; + --ion-color-tertiary-shade: #6a1779; + --ion-color-tertiary-tint: #863196; - --ion-toolbar-background: #000000; - --ion-toolbar-color: #ffffff; - --ion-toolbar-border-color: #ffffff; + /* success #146c2e on cream ~7.4:1 */ + --ion-color-success: #146c2e; + --ion-color-success-rgb: 20,108,46; + --ion-color-success-contrast: #ffffff; + --ion-color-success-contrast-rgb: 255,255,255; + --ion-color-success-shade: #115f29; + --ion-color-success-tint: #2b7a44; + + /* warning #7a4a00 (dark amber) on cream ~7.5:1 */ + --ion-color-warning: #7a4a00; + --ion-color-warning-rgb: 122,74,0; + --ion-color-warning-contrast: #ffffff; + --ion-color-warning-contrast-rgb: 255,255,255; + --ion-color-warning-shade: #6b4100; + --ion-color-warning-tint: #875c1a; + + /* danger #a30000 on cream ~9.0:1 */ + --ion-color-danger: #a30000; + --ion-color-danger-rgb: 163,0,0; + --ion-color-danger-contrast: #ffffff; + --ion-color-danger-contrast-rgb: 255,255,255; + --ion-color-danger-shade: #8f0000; + --ion-color-danger-tint: #ad1a1a; - --ion-tab-bar-background: #000000; - --ion-tab-bar-color: #ffffff; - --ion-tab-bar-color-selected: #ffff00; - --ion-tab-bar-border-color: #ffffff; + --ion-color-dark: #1a1d24; + --ion-color-dark-rgb: 26,29,36; + --ion-color-dark-contrast: #ffffff; + --ion-color-dark-contrast-rgb: 255,255,255; + --ion-color-dark-shade: #171a20; + --ion-color-dark-tint: #31343a; - --ion-card-background: #000000; - --ion-card-color: #ffffff; + --ion-color-medium: #4a4d54; + --ion-color-medium-rgb: 74,77,84; + --ion-color-medium-contrast: #ffffff; + --ion-color-medium-contrast-rgb: 255,255,255; + --ion-color-medium-shade: #41444a; + --ion-color-medium-tint: #5c5f65; + + --ion-color-light: #f0eee8; + --ion-color-light-rgb: 240,238,232; + --ion-color-light-contrast: #1a1d24; + --ion-color-light-contrast-rgb: 26,29,36; + --ion-color-light-shade: #d3d1cc; + --ion-color-light-tint: #f2f1eb; + + /* Favorite: deep purple matches primary so favorited rows blend with brand. */ + --ion-color-favorite: #4a0072; + --ion-color-favorite-rgb: 74,0,114; + --ion-color-favorite-contrast: #ffffff; + --ion-color-favorite-contrast-rgb: 255,255,255; + --ion-color-favorite-shade: #410064; + --ion-color-favorite-tint: #5c1a80; + + /* Cream background reads as paper rather than glaring sheet-of-paper white. */ + --ion-background-color: #fbfaf6; + --ion-background-color-rgb: 251,250,246; + + /* Near-black text on cream ~17.6:1 */ + --ion-text-color: #1a1d24; + --ion-text-color-rgb: 26,29,36; + + --ion-border-color: #1a1d24; + + --ion-color-step-50: #f2f0eb; + --ion-color-step-100: #e9e7e1; + --ion-color-step-150: #e0ddd6; + --ion-color-step-200: #d6d3cb; + --ion-color-step-250: #cdc9c0; + --ion-color-step-300: #b8b4ab; + --ion-color-step-350: #a3a097; + --ion-color-step-400: #8e8b83; + --ion-color-step-450: #7a766f; + --ion-color-step-500: #4a4d54; + --ion-color-step-550: #41444a; + --ion-color-step-600: #383b41; + --ion-color-step-650: #2f3137; + --ion-color-step-700: #26282d; + --ion-color-step-750: #1f2126; + --ion-color-step-800: #1a1d24; + --ion-color-step-850: #15171c; + --ion-color-step-900: #0d0e12; + --ion-color-step-950: #050608; + + --ion-item-background: #fbfaf6; + --ion-item-color: #1a1d24; + --ion-item-border-color: #1a1d24; + + --ion-toolbar-background: #fbfaf6; + --ion-toolbar-color: #1a1d24; + --ion-toolbar-border-color: #1a1d24; + + --ion-tab-bar-background: #fbfaf6; + --ion-tab-bar-color: #1a1d24; + --ion-tab-bar-color-selected: #4a0072; + --ion-tab-bar-border-color: #1a1d24; + + --ion-card-background: #ffffff; + --ion-card-color: #1a1d24; } -.high-contrast-theme.ios, -.high-contrast-theme.md { - --ion-background-color: #000000; - --ion-background-color-rgb: 0,0,0; - --ion-text-color: #ffffff; - --ion-text-color-rgb: 255,255,255; - --ion-toolbar-background: #000000; - --ion-item-background: #000000; - --ion-tab-bar-background: #000000; +/* HC Dark: deep ink background, warm off-white text, Apple-yellow accent. */ +.high-contrast-dark-theme { + /* primary #ffd60a on ink ~13.6:1 */ + --ion-color-primary: #ffd60a; + --ion-color-primary-rgb: 255,214,10; + --ion-color-primary-contrast: #0b0f17; + --ion-color-primary-contrast-rgb: 11,15,23; + --ion-color-primary-shade: #e0bc09; + --ion-color-primary-tint: #ffda23; + + /* secondary #5ac8fa on ink ~9.8:1 */ + --ion-color-secondary: #5ac8fa; + --ion-color-secondary-rgb: 90,200,250; + --ion-color-secondary-contrast: #0b0f17; + --ion-color-secondary-contrast-rgb: 11,15,23; + --ion-color-secondary-shade: #4fb0dc; + --ion-color-secondary-tint: #6bcefb; + + /* tertiary #c084ff on ink ~7.6:1 */ + --ion-color-tertiary: #c084ff; + --ion-color-tertiary-rgb: 192,132,255; + --ion-color-tertiary-contrast: #0b0f17; + --ion-color-tertiary-contrast-rgb: 11,15,23; + --ion-color-tertiary-shade: #a974e0; + --ion-color-tertiary-tint: #c690ff; + + /* success #30d158 on ink ~10.5:1 */ + --ion-color-success: #30d158; + --ion-color-success-rgb: 48,209,88; + --ion-color-success-contrast: #0b0f17; + --ion-color-success-contrast-rgb: 11,15,23; + --ion-color-success-shade: #2ab84d; + --ion-color-success-tint: #45d669; + + /* warning #ffd60a on ink ~13.6:1 */ + --ion-color-warning: #ffd60a; + --ion-color-warning-rgb: 255,214,10; + --ion-color-warning-contrast: #0b0f17; + --ion-color-warning-contrast-rgb: 11,15,23; + --ion-color-warning-shade: #e0bc09; + --ion-color-warning-tint: #ffda23; + + /* danger #ff8a80 on ink ~7.4:1 */ + --ion-color-danger: #ff8a80; + --ion-color-danger-rgb: 255,138,128; + --ion-color-danger-contrast: #0b0f17; + --ion-color-danger-contrast-rgb: 11,15,23; + --ion-color-danger-shade: #e07971; + --ion-color-danger-tint: #ff968d; + + --ion-color-dark: #f5f5f0; + --ion-color-dark-rgb: 245,245,240; + --ion-color-dark-contrast: #0b0f17; + --ion-color-dark-contrast-rgb: 11,15,23; + --ion-color-dark-shade: #d8d8d3; + --ion-color-dark-tint: #f6f6f2; + + --ion-color-medium: #b0b3ba; + --ion-color-medium-rgb: 176,179,186; + --ion-color-medium-contrast: #0b0f17; + --ion-color-medium-contrast-rgb: 11,15,23; + --ion-color-medium-shade: #9b9ea3; + --ion-color-medium-tint: #b8bbc1; + + --ion-color-light: #1a1d24; + --ion-color-light-rgb: 26,29,36; + --ion-color-light-contrast: #f5f5f0; + --ion-color-light-contrast-rgb: 245,245,240; + --ion-color-light-shade: #171a20; + --ion-color-light-tint: #31343a; + + --ion-color-favorite: #ffd60a; + --ion-color-favorite-rgb: 255,214,10; + --ion-color-favorite-contrast: #0b0f17; + --ion-color-favorite-contrast-rgb: 11,15,23; + --ion-color-favorite-shade: #e0bc09; + --ion-color-favorite-tint: #ffda23; + + --ion-background-color: #0b0f17; + --ion-background-color-rgb: 11,15,23; + + --ion-text-color: #f5f5f0; + --ion-text-color-rgb: 245,245,240; + + --ion-border-color: #f5f5f0; + + --ion-color-step-50: #11151d; + --ion-color-step-100: #161a23; + --ion-color-step-150: #1c2029; + --ion-color-step-200: #21252f; + --ion-color-step-250: #272b35; + --ion-color-step-300: #2d313b; + --ion-color-step-350: #393d47; + --ion-color-step-400: #494d57; + --ion-color-step-450: #595d67; + --ion-color-step-500: #b0b3ba; + --ion-color-step-550: #b9bcc2; + --ion-color-step-600: #c1c4ca; + --ion-color-step-650: #cacdd2; + --ion-color-step-700: #d2d5da; + --ion-color-step-750: #dbdde2; + --ion-color-step-800: #e3e5ea; + --ion-color-step-850: #ecedf0; + --ion-color-step-900: #f0f1f3; + --ion-color-step-950: #f5f5f0; + + --ion-item-background: #0b0f17; + --ion-item-color: #f5f5f0; + --ion-item-border-color: #f5f5f0; + + --ion-toolbar-background: #0b0f17; + --ion-toolbar-color: #f5f5f0; + --ion-toolbar-border-color: #f5f5f0; + + --ion-tab-bar-background: #0b0f17; + --ion-tab-bar-color: #f5f5f0; + --ion-tab-bar-color-selected: #ffd60a; + --ion-tab-bar-border-color: #f5f5f0; + + --ion-card-background: #11151d; + --ion-card-color: #f5f5f0; } +/* + * Cross-cutting affordances applied to both HC variants. Borders are reserved + * for major surfaces (cards, toolbar, tab bar) so list items and buttons stay + * visually quiet — overlining every control made the previous pass feel like a + * fenced-in test pattern. + */ .high-contrast-theme { ion-toolbar { - --border-width: 0 0 2px 0; - --border-color: #ffffff; + --border-width: 0 0 1px 0; + --border-color: var(--ion-border-color); } ion-tab-bar { - border-top: 2px solid #ffffff; + border-top: 1px solid var(--ion-border-color); } ion-card { - border: 2px solid #ffffff; + border: 1px solid var(--ion-border-color); box-shadow: none; } - ion-item { - --border-color: #ffffff; - } - - ion-button { - --border-width: 2px; - --border-style: solid; - --border-color: currentColor; - } - - ion-segment-button { - --border-color: #ffffff; - } - - a, + /* Visible focus ring is the load-bearing affordance for keyboard nav. */ + a:focus-visible, button:focus-visible, ion-button:focus-visible, ion-item:focus-visible, ion-tab-button:focus-visible, ion-segment-button:focus-visible, + ion-select:focus-visible, [tabindex]:focus-visible { - outline: 3px solid #ffff00; + outline: 3px solid var(--ion-color-primary); outline-offset: 2px; } } From d11efef384e2ccb61ccb29d5550f46f449708329 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 28 Apr 2026 14:28:43 -0500 Subject: [PATCH 3/3] fix speaker text being a little too hard to read --- src/global.scss | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/global.scss b/src/global.scss index 1a4634de..90fa3840 100644 --- a/src/global.scss +++ b/src/global.scss @@ -79,6 +79,27 @@ ion-content [innerHTML] { } } +/* + * High Contrast: collapse Ionic's secondary-text hierarchy. + * + * Ionic styles

inside with --ion-color-step-600 (mid-gray) + * to create a "primary title / secondary subtitle" visual hierarchy, and + * with step-500. In a HC theme, mid-grays defeat the entire + * point — every text element should sit at body-text contrast (7:1+), + * not at the 4-5:1 muted range. Force secondary text up to full text + * color so speaker subtitles, session-time strings, and notes match the + * legibility of the title beside them. + */ +.high-contrast-theme { + ion-label p, + ion-label h3 + p, + ion-note, + ion-item ion-note[slot] { + color: var(--ion-text-color); + opacity: 1; + } +} + /* * Branded-header toolbar buttons. *