Skip to content

Commit a55716e

Browse files
committed
feat: themes for a11y
1 parent 8ff60db commit a55716e

9 files changed

Lines changed: 413 additions & 60 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "pkg-diff",
33
"private": true,
44
"type": "module",
5-
"version": "0.0.1",
5+
"version": "0.0.2",
66
"license": "GPL-3.0-only",
77
"packageManager": "pnpm@10.26.1",
88
"scripts": {

scripts/gen-themes.mjs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { execFileSync } from 'node:child_process'
2+
import { readFileSync, writeFileSync } from 'node:fs'
3+
import { createRequire } from 'node:module'
4+
import { pathToFileURL } from 'node:url'
5+
6+
const require = createRequire(import.meta.resolve('@pierre/diffs'))
7+
8+
const IDS = [
9+
'pierre-dark-vibrant',
10+
'pierre-dark-protanopia-deuteranopia',
11+
'pierre-dark-tritanopia',
12+
]
13+
14+
const ACHROMATOPSIA = 'achromatopsia'
15+
16+
const ACCENTS = {
17+
primary: 'button.background',
18+
secondary: 'gitDecoration.conflictingResourceForeground',
19+
error: 'gitDecoration.deletedResourceForeground',
20+
info: 'gitDecoration.modifiedResourceForeground',
21+
success: 'gitDecoration.addedResourceForeground',
22+
warning: 'notificationsWarningIcon.foreground',
23+
}
24+
25+
const OVERRIDES = {
26+
'pierre-dark-vibrant': { primary: 'oklch(0.811 0.17 293.6)' },
27+
'pierre-dark-protanopia-deuteranopia': { primary: '#c4b5fd' },
28+
'pierre-dark-tritanopia': { primary: 'oklch(0.811 0.101 195)', info: 'oklch(0.62 0.205 276)' },
29+
[ACHROMATOPSIA]: { primary: '#c4b5fd' },
30+
}
31+
32+
function channels (color) {
33+
const p3 = color.match(/color\(display-p3\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\)/)
34+
if (p3) return [+p3[1], +p3[2], +p3[3]]
35+
const hex = color.replace('#', '')
36+
const pairs = hex.length === 3 ? [...hex].map(c => c + c) : hex.match(/../g)
37+
return pairs.slice(0, 3).map(p => Number.parseInt(p, 16) / 255)
38+
}
39+
40+
function linear (color) {
41+
return channels(color).map(v => (v <= 0.040_45 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
42+
}
43+
44+
function luminance (color) {
45+
const oklch = color.match(/oklch\(([\d.]+)/)
46+
if (oklch) return Number(oklch[1]) ** 3
47+
const [r, g, b] = linear(color)
48+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
49+
}
50+
51+
/** Oklab's L, i.e. the lightness that survives when the chroma is dropped. */
52+
function lightness (color) {
53+
const oklch = color.match(/oklch\(([\d.]+)/)
54+
if (oklch) return Number(oklch[1])
55+
const [r, g, b] = linear(color)
56+
const l = Math.cbrt(0.412_221_470_8 * r + 0.536_332_536_3 * g + 0.051_445_992_9 * b)
57+
const m = Math.cbrt(0.211_903_498_2 * r + 0.680_699_545_1 * g + 0.107_396_956_6 * b)
58+
const s = Math.cbrt(0.088_302_461_9 * r + 0.281_718_837_6 * g + 0.629_978_700_5 * b)
59+
return 0.210_454_255_3 * l + 0.793_617_785 * m - 0.004_072_046_8 * s
60+
}
61+
62+
/**
63+
* Achromatopsia sees no hue at all, so every color collapses to its own
64+
* lightness. Additions and deletions end up near-identical grays — the diff
65+
* switches to Pierre's literal +/- indicators to carry that signal instead.
66+
*/
67+
function achromatic (record) {
68+
const colors = Object.fromEntries(
69+
Object.entries(record.colors).map(([key, value]) => [key, `oklch(${lightness(value).toFixed(3)} 0 0)`]),
70+
)
71+
// Graying moves an accent's lightness, so its foreground has to be picked
72+
// again against the gray rather than inherited from the color it replaced.
73+
for (const name of Object.keys(ACCENTS)) {
74+
colors[`on-${name}`] = lightness(colors[name]) > 0.55 ? colors.background : colors['on-background']
75+
}
76+
return { dark: record.dark, colors }
77+
}
78+
79+
function toV0 (theme, id) {
80+
const c = theme.colors
81+
const bg = c['editor.background']
82+
const fg = c['editor.foreground']
83+
// Foreground for text sitting on an accent fill: whichever of the theme's own
84+
// extremes contrasts with it.
85+
const on = accent => (luminance(accent) > 0.36 ? bg : fg)
86+
87+
const accents = Object.fromEntries(Object.entries(ACCENTS).map(([name, key]) => [name, c[key]]))
88+
Object.assign(accents, OVERRIDES[id])
89+
90+
return {
91+
dark: theme.type === 'dark',
92+
colors: {
93+
...accents,
94+
'background': bg,
95+
'surface': c['sideBar.background'],
96+
'surface-tint': c['input.background'],
97+
'surface-variant': c['editorIndentGuide.activeBackground'],
98+
'divider': c['editorLineNumber.foreground'],
99+
...Object.fromEntries(Object.entries(accents).map(([k, v]) => [`on-${k}`, on(v)])),
100+
'on-background': fg,
101+
'on-surface': fg,
102+
'on-surface-variant': c['sideBar.foreground'],
103+
},
104+
}
105+
}
106+
107+
const load = id => require(`@pierre/theme/themes/${id}.json`)
108+
109+
const themes = {
110+
...Object.fromEntries(IDS.map(id => [id, toV0(load(id), id)])),
111+
[ACHROMATOPSIA]: achromatic(toV0(load('pierre-dark'), ACHROMATOPSIA)),
112+
}
113+
114+
const manifest = new URL('../package.json', pathToFileURL(require.resolve('@pierre/theme/themes/pierre-dark.json')))
115+
const { version } = JSON.parse(readFileSync(manifest, 'utf8'))
116+
117+
const source = `/**
118+
* Pierre's editor themes mapped onto v0's palette — the colorblind-safe variants
119+
* matter most for reading a diff. Generated from @pierre/theme@${version} by
120+
* \`node scripts/gen-themes.mjs\`; edit that script, not this file.
121+
*/
122+
123+
import type { ThemeRecord } from '@vuetify/v0'
124+
125+
export const pierreThemes: Record<string, ThemeRecord> = ${JSON.stringify(themes, null, 2)}
126+
`
127+
128+
const out = new URL('../src/lib/pierre-themes.ts', import.meta.url)
129+
writeFileSync(out, source)
130+
execFileSync('pnpm', ['lint:fix', 'src/lib/pierre-themes.ts'], { stdio: 'inherit' })
131+
console.log(`wrote ${Object.keys(themes).length} themes to ${out.pathname}`)

src/App.vue

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
<script lang="ts" setup>
2+
import { useTheme } from '@vuetify/v0'
3+
import { watch } from 'vue'
24
import DiffApp from '@/components/DiffApp.vue'
5+
import { DEFAULT_THEME, themeChoice } from '@/lib/storage'
6+
7+
const theme = useTheme()
8+
9+
watch(themeChoice, id => {
10+
if (id in theme.colors.value) theme.select(id)
11+
else themeChoice.value = DEFAULT_THEME
12+
}, { immediate: true })
313
</script>
414

515
<template>

src/components/DiffApp.vue

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
import { useDiff } from '@/composables/useDiff'
77
import { useRecentPackages } from '@/composables/useRecentPackages'
88
import { getRepoSlug, listVersions, resolveTarball } from '@/lib/registry'
9+
import { ACHROMATIC_THEME, themeChoice } from '@/lib/storage'
910
import AutocompleteInput from './AutocompleteInput.vue'
1011
import CopyButton from './CopyButton.vue'
1112
import ExcludeFilters from './ExcludeFilters.vue'
1213
import LoadingState from './LoadingState.vue'
1314
import PierreFileDiff from './PierreFileDiff.vue'
1415
import PierreTree from './PierreTree.vue'
16+
import ThemeMenu from './ThemeMenu.vue'
1517
1618
const { recent, remember } = useRecentPackages()
1719
const { abort, aborting, result, loading, stage, detail, error, compare } = useDiff()
@@ -132,6 +134,10 @@
132134
// structured clone into the worker.
133135
const excludePatterns = shallowRef<string[]>([])
134136
137+
// Pierre's syntax tokens and file icons come from its own shadow-DOM styles, so
138+
// the grayscale theme has to desaturate the whole pane to reach them.
139+
const achromatic = computed(() => themeChoice.value === ACHROMATIC_THEME)
140+
135141
const scope = ref<Scope[]>(['lib', 'dist', 'other'])
136142
const scopeOptions: { id: Scope, label: string }[] = [
137143
{ id: 'lib', label: 'lib' },
@@ -398,30 +404,33 @@
398404
</span>
399405
</div>
400406

401-
<!-- Scope filter -->
402-
<Selection.Root v-slot="{ attrs }" v-model="scope" multiple>
403-
<div v-bind="attrs" aria-label="Filter by scope" class="flex gap-2 mb-4">
404-
<Selection.Item
405-
v-for="opt in scopeOptions"
406-
:key="opt.id"
407-
v-slot="{ isSelected, toggle }"
408-
:value="opt.id"
409-
>
410-
<button
411-
:aria-pressed="isSelected"
412-
class="px-3 py-1.5 rounded-lg border text-sm font-medium transition-colors"
413-
:class="isSelected
414-
? 'bg-primary text-on-primary border-primary'
415-
: 'bg-surface-tint hover:bg-surface-variant border-subtle text-on-surface'"
416-
type="button"
417-
@click="toggle"
407+
<div class="flex items-center gap-2 mb-4">
408+
<Selection.Root v-slot="{ attrs }" v-model="scope" multiple>
409+
<div v-bind="attrs" aria-label="Filter by scope" class="flex gap-2">
410+
<Selection.Item
411+
v-for="opt in scopeOptions"
412+
:key="opt.id"
413+
v-slot="{ isSelected, toggle }"
414+
:value="opt.id"
418415
>
419-
{{ opt.label }}
420-
<span class="opacity-60">({{ scopeCounts[opt.id] }})</span>
421-
</button>
422-
</Selection.Item>
423-
</div>
424-
</Selection.Root>
416+
<button
417+
:aria-pressed="isSelected"
418+
class="px-3 py-1.5 rounded-lg border text-sm font-medium transition-colors"
419+
:class="isSelected
420+
? 'bg-primary text-on-primary border-primary'
421+
: 'bg-surface-tint hover:bg-surface-variant border-subtle text-on-surface'"
422+
type="button"
423+
@click="toggle"
424+
>
425+
{{ opt.label }}
426+
<span class="opacity-60">({{ scopeCounts[opt.id] }})</span>
427+
</button>
428+
</Selection.Item>
429+
</div>
430+
</Selection.Root>
431+
432+
<ThemeMenu class="ml-auto" />
433+
</div>
425434

426435
<!-- Sidebar (tree) + diff content -->
427436
<div
@@ -434,6 +443,7 @@
434443
<div
435444
v-else
436445
class="grid grid-cols-[minmax(220px,300px)_1fr] gap-4 h-[70vh] min-h-[400px]"
446+
:class="{ grayscale: achromatic }"
437447
>
438448
<aside class="rounded-xl border border-subtle bg-surface overflow-hidden">
439449
<PierreTree :active="activePath" :files="visibleFiles" @select="activePath = $event" />

src/components/PierreFileDiff.vue

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
import type { FileEntry, FileStatus, PkgRef } from '@/lib/types'
33
import { FileDiff, processFile } from '@pierre/diffs'
44
import { useMediaQuery } from '@vuetify/v0'
5-
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
5+
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
6+
import { ACHROMATIC_THEME, themeChoice } from '@/lib/storage'
67
import CopyButton from './CopyButton.vue'
78
89
const props = defineProps<{ file: FileEntry, pkgA?: PkgRef, pkgB?: PkgRef, shareUrl?: string }>()
@@ -32,6 +33,8 @@
3233
// Pierre's literal +/- glyphs and drop the now-meaningless backgrounds.
3334
const { matches: forcedColors } = useMediaQuery('(forced-colors: active)')
3435
36+
const glyphIndicators = computed(() => forcedColors.value || themeChoice.value === ACHROMATIC_THEME)
37+
3538
const headerLabel: Record<FileStatus, string> = {
3639
added: 'Added',
3740
removed: 'Removed',
@@ -65,7 +68,7 @@
6568
themeType: 'dark',
6669
overflow: 'scroll',
6770
disableFileHeader: true,
68-
diffIndicators: forcedColors.value ? 'classic' : 'bars',
71+
diffIndicators: glyphIndicators.value ? 'classic' : 'bars',
6972
disableBackground: forcedColors.value,
7073
})
7174
@@ -78,8 +81,9 @@
7881
onMounted(render)
7982
8083
// Recreate the instance per file (cheap, avoids stale internal DOM/state) and
81-
// when forced-colors toggles, since diffIndicators is a constructor option.
82-
watch([() => props.file, forcedColors], () => {
84+
// whenever the indicator mode flips, since diffIndicators is a constructor
85+
// option.
86+
watch([() => props.file, forcedColors, glyphIndicators], () => {
8387
instance?.cleanUp()
8488
instance = null
8589
render()

0 commit comments

Comments
 (0)