diff --git a/src/app/app.component.html b/src/app/app.component.html index c4ea69cd..59ab0f36 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,4 +1,9 @@ - + + Skip to main content @@ -189,17 +194,27 @@ Install Latest Update - - - Dark Mode - + + + + Light + Dark + High Contrast (Light) + High Contrast (Dark) + - + diff --git a/src/app/app.component.scss b/src/app/app.component.scss index 4ac9a5ab..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; @@ -90,6 +117,14 @@ ion-item.indent { padding-left: 1.25em; } +/* + * 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-item ion-select { + font-weight: 500; +} + 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..e706f1bc 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: var(--ion-card-background); + border: 1px solid var(--ion-border-color); +} + .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..2918e7a4 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: var(--ion-color-primary); + background: var(--ion-card-background); + color: var(--ion-color-primary); + border: 1px solid var(--ion-border-color); + 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: 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 eeb7c30c..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,6 +29,14 @@ $tracks: ( --pycon-accent: #DD04D2; } +:host-context(.high-contrast-light-theme) { + --pycon-accent: #4a0072; +} + +:host-context(.high-contrast-dark-theme) { + --pycon-accent: #ffd60a; +} + .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..985acfab 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: var(--ion-card-background); + border: 1px solid var(--ion-border-color); + 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: var(--ion-color-primary); + text-decoration-thickness: 2px; +} + +:host-context(.high-contrast-theme) .meta-row ion-icon { + color: var(--ion-color-primary); +} + /* * Body content below the card */ 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 da8bd5b2..1e078969 100644 --- a/src/app/providers/user-data.ts +++ b/src/app/providers/user-data.ts @@ -14,6 +14,17 @@ export function isCustomScheduleFilter(excluded: ReadonlyArray): boolean return !excluded.every(track => defaults.has(track)); } +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' @@ -77,16 +88,32 @@ 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 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; + } + 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/global.scss b/src/global.scss index 0d13e9cb..90fa3840 100644 --- a/src/global.scss +++ b/src/global.scss @@ -60,6 +60,65 @@ 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; + } +} + +/* + * 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. + * + * 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 +133,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 7bb20392..2f9a93e9 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -416,3 +416,299 @@ --ion-tab-bar-background: #1f1f1f; } + +/* + * High Contrast Themes (a11y) + * ---------------------------------------------------------------------------- + * 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. + */ + +/* 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; + + /* 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; + + /* 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; + + /* 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-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-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; +} + +/* 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 1px 0; + --border-color: var(--ion-border-color); + } + + ion-tab-bar { + border-top: 1px solid var(--ion-border-color); + } + + ion-card { + border: 1px solid var(--ion-border-color); + box-shadow: none; + } + + /* 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 var(--ion-color-primary); + outline-offset: 2px; + } +}
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. + * + * 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 +133,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 7bb20392..2f9a93e9 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -416,3 +416,299 @@ --ion-tab-bar-background: #1f1f1f; } + +/* + * High Contrast Themes (a11y) + * ---------------------------------------------------------------------------- + * 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. + */ + +/* 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; + + /* 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; + + /* 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; + + /* 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-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-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; +} + +/* 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 1px 0; + --border-color: var(--ion-border-color); + } + + ion-tab-bar { + border-top: 1px solid var(--ion-border-color); + } + + ion-card { + border: 1px solid var(--ion-border-color); + box-shadow: none; + } + + /* 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 var(--ion-color-primary); + outline-offset: 2px; + } +}