@rokkit/cli 1.3.1 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/contrast.d.ts +40 -0
- package/dist/src/doctor.d.ts +17 -0
- package/package.json +2 -2
- package/src/contrast.js +176 -0
- package/src/doctor.js +94 -3
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a colour value into OKLCH components [L, C, H].
|
|
3
|
+
* Accepts a palette ref ("kami.400"), a bare "L C H" string ("0.985 0.005 85"),
|
|
4
|
+
* or an `oklch(L C H)` function. Returns null for anything else (e.g. hex/rgb).
|
|
5
|
+
* @param {string} value
|
|
6
|
+
* @param {Record<string, Record<string|number,string>>} palettes
|
|
7
|
+
* @returns {[number, number, number] | null}
|
|
8
|
+
*/
|
|
9
|
+
export function parseColor(value: string, palettes?: Record<string, Record<string | number, string>>): [number, number, number] | null;
|
|
10
|
+
/**
|
|
11
|
+
* OKLCH → linear sRGB (the space WCAG relative luminance is defined in).
|
|
12
|
+
* @param {[number, number, number]} oklch
|
|
13
|
+
* @returns {[number, number, number]} linear sRGB, each clamped to [0,1]
|
|
14
|
+
*/
|
|
15
|
+
export function oklchToLinearSrgb([L, C, H]: [number, number, number]): [number, number, number];
|
|
16
|
+
/**
|
|
17
|
+
* WCAG relative luminance from OKLCH components.
|
|
18
|
+
* @param {[number, number, number]} oklch
|
|
19
|
+
* @returns {number}
|
|
20
|
+
*/
|
|
21
|
+
export function relativeLuminance(oklch: [number, number, number]): number;
|
|
22
|
+
/**
|
|
23
|
+
* WCAG contrast ratio between two OKLCH colours.
|
|
24
|
+
* @param {[number, number, number]} a
|
|
25
|
+
* @param {[number, number, number]} b
|
|
26
|
+
* @returns {number}
|
|
27
|
+
*/
|
|
28
|
+
export function contrastRatio(a: [number, number, number], b: [number, number, number]): number;
|
|
29
|
+
/**
|
|
30
|
+
* Verify text/border contrast tokens from the parsed config, in light + dark.
|
|
31
|
+
* @param {Record<string, any> | null} config
|
|
32
|
+
* @returns {Array<{ id: string, label: string, status: 'warn', fixable: false, fix: string }>}
|
|
33
|
+
*/
|
|
34
|
+
export function checkContrastTokens(config: Record<string, any> | null): Array<{
|
|
35
|
+
id: string;
|
|
36
|
+
label: string;
|
|
37
|
+
status: "warn";
|
|
38
|
+
fixable: false;
|
|
39
|
+
fix: string;
|
|
40
|
+
}>;
|
package/dist/src/doctor.d.ts
CHANGED
|
@@ -32,6 +32,23 @@ export function validateConfigShape(config: Record<string, unknown> | null): Arr
|
|
|
32
32
|
fixable: false;
|
|
33
33
|
fix: string;
|
|
34
34
|
}>;
|
|
35
|
+
/**
|
|
36
|
+
* Advisory lint: scan src/ for surface/border tokens used as text colour.
|
|
37
|
+
* @param {{ list?: (dir: string) => string[], exists: (p: string) => boolean, read: (p: string) => string, resolve: (p: string) => string }} fs
|
|
38
|
+
* @returns {Array<{ id: string, label: string, status: 'warn', fixable: false, fix: string }>}
|
|
39
|
+
*/
|
|
40
|
+
export function checkTextTokenUsage(fs: {
|
|
41
|
+
list?: (dir: string) => string[];
|
|
42
|
+
exists: (p: string) => boolean;
|
|
43
|
+
read: (p: string) => string;
|
|
44
|
+
resolve: (p: string) => string;
|
|
45
|
+
}): Array<{
|
|
46
|
+
id: string;
|
|
47
|
+
label: string;
|
|
48
|
+
status: "warn";
|
|
49
|
+
fixable: false;
|
|
50
|
+
fix: string;
|
|
51
|
+
}>;
|
|
35
52
|
/**
|
|
36
53
|
* Interactive doctor command — validates project setup.
|
|
37
54
|
* @param {{ fix?: boolean }} [opts]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rokkit/cli",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
4
4
|
"description": "Command line utilities for icon bundling and conversion.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@mdi/svg": "^7.4.47",
|
|
30
|
-
"@rokkit/unocss": "1.3.
|
|
30
|
+
"@rokkit/unocss": "1.3.3",
|
|
31
31
|
"sade": "^1.8.1",
|
|
32
32
|
"@iconify/tools": "5.0.3",
|
|
33
33
|
"@iconify/types": "^2.0.0",
|
package/src/contrast.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static contrast checks for `rokkit doctor`.
|
|
3
|
+
*
|
|
4
|
+
* Computes real WCAG contrast (not just OKLCH lightness diff) from the project's
|
|
5
|
+
* configured palettes, in BOTH light and dark modes, and verifies:
|
|
6
|
+
* - `ink` and `ink-mute` (primary + secondary text) clear AA 4.5 on `paper`,
|
|
7
|
+
* - the text ramp is monotonic: ink > ink-mute > ink-soft > ink-faint,
|
|
8
|
+
* - `paper-edge` (the hairline border tone) is actually visible against `paper`
|
|
9
|
+
* (catches the "near-black etched edge invisible in dark" class of bug).
|
|
10
|
+
*
|
|
11
|
+
* All findings are advisory (`warn`) — a project's palette is the author's call;
|
|
12
|
+
* the doctor surfaces problems, it doesn't block.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const PALETTE_REF = /^([a-z][\w-]*)\.(\d{2,3})$/i
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a colour value into OKLCH components [L, C, H].
|
|
19
|
+
* Accepts a palette ref ("kami.400"), a bare "L C H" string ("0.985 0.005 85"),
|
|
20
|
+
* or an `oklch(L C H)` function. Returns null for anything else (e.g. hex/rgb).
|
|
21
|
+
* @param {string} value
|
|
22
|
+
* @param {Record<string, Record<string|number,string>>} palettes
|
|
23
|
+
* @returns {[number, number, number] | null}
|
|
24
|
+
*/
|
|
25
|
+
export function parseColor(value, palettes = {}) {
|
|
26
|
+
if (typeof value !== 'string') return null
|
|
27
|
+
let raw = value.trim()
|
|
28
|
+
const ref = raw.match(PALETTE_REF)
|
|
29
|
+
if (ref) {
|
|
30
|
+
const shade = palettes[ref[1]]?.[ref[2]]
|
|
31
|
+
if (!shade) return null
|
|
32
|
+
raw = String(shade).trim()
|
|
33
|
+
}
|
|
34
|
+
const fn = raw.match(/^oklch\(\s*([^)]+)\)$/i)
|
|
35
|
+
if (fn) raw = fn[1].trim()
|
|
36
|
+
const parts = raw.split(/[\s,/]+/).filter(Boolean).map(Number)
|
|
37
|
+
if (parts.length < 3 || parts.some((n) => Number.isNaN(n))) return null
|
|
38
|
+
return [parts[0], parts[1], parts[2]]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* OKLCH → linear sRGB (the space WCAG relative luminance is defined in).
|
|
43
|
+
* @param {[number, number, number]} oklch
|
|
44
|
+
* @returns {[number, number, number]} linear sRGB, each clamped to [0,1]
|
|
45
|
+
*/
|
|
46
|
+
export function oklchToLinearSrgb([L, C, H]) {
|
|
47
|
+
const hr = (H * Math.PI) / 180
|
|
48
|
+
const a = C * Math.cos(hr)
|
|
49
|
+
const b = C * Math.sin(hr)
|
|
50
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b
|
|
51
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b
|
|
52
|
+
const s_ = L - 0.0894841775 * a - 1.291485548 * b
|
|
53
|
+
const l = l_ ** 3
|
|
54
|
+
const m = m_ ** 3
|
|
55
|
+
const s = s_ ** 3
|
|
56
|
+
const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
|
|
57
|
+
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
|
|
58
|
+
const bl = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
|
|
59
|
+
const clamp = (v) => Math.max(0, Math.min(1, v))
|
|
60
|
+
return [clamp(r), clamp(g), clamp(bl)]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* WCAG relative luminance from OKLCH components.
|
|
65
|
+
* @param {[number, number, number]} oklch
|
|
66
|
+
* @returns {number}
|
|
67
|
+
*/
|
|
68
|
+
export function relativeLuminance(oklch) {
|
|
69
|
+
const [r, g, b] = oklchToLinearSrgb(oklch)
|
|
70
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* WCAG contrast ratio between two OKLCH colours.
|
|
75
|
+
* @param {[number, number, number]} a
|
|
76
|
+
* @param {[number, number, number]} b
|
|
77
|
+
* @returns {number}
|
|
78
|
+
*/
|
|
79
|
+
export function contrastRatio(a, b) {
|
|
80
|
+
const la = relativeLuminance(a)
|
|
81
|
+
const lb = relativeLuminance(b)
|
|
82
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Pick a skin role's palette name for a mode (handles `{light,dark}` duals). */
|
|
86
|
+
function roleName(skin, role, mode) {
|
|
87
|
+
const v = skin?.[role]
|
|
88
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) return mode === 'dark' ? v.dark : v.light
|
|
89
|
+
return v
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve a named token's OKLCH for a mode: config `overrides` win, else the
|
|
94
|
+
* role palette at the token's shade.
|
|
95
|
+
*/
|
|
96
|
+
function resolveToken(config, skin, role, shade, name, mode) {
|
|
97
|
+
const ov = config.overrides?.[name]
|
|
98
|
+
if (ov !== undefined) {
|
|
99
|
+
const v = ov && typeof ov === 'object' && !Array.isArray(ov) ? (mode === 'dark' ? ov.dark : ov.light) : ov
|
|
100
|
+
return parseColor(v, config.palettes)
|
|
101
|
+
}
|
|
102
|
+
const pal = config.palettes?.[roleName(skin, role, mode)]
|
|
103
|
+
return pal ? parseColor(String(pal[shade]), config.palettes) : null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const AA = 4.5
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Verify text/border contrast tokens from the parsed config, in light + dark.
|
|
110
|
+
* @param {Record<string, any> | null} config
|
|
111
|
+
* @returns {Array<{ id: string, label: string, status: 'warn', fixable: false, fix: string }>}
|
|
112
|
+
*/
|
|
113
|
+
export function checkContrastTokens(config) {
|
|
114
|
+
if (!config?.palettes) return []
|
|
115
|
+
const skin = config.skins?.default ?? config.skin ?? config.colors
|
|
116
|
+
if (!skin || !skin.ink || !skin.surface) return []
|
|
117
|
+
|
|
118
|
+
const findings = []
|
|
119
|
+
for (const mode of ['light', 'dark']) {
|
|
120
|
+
const paper = resolveToken(config, skin, 'surface', 50, 'paper', mode)
|
|
121
|
+
if (!paper) continue
|
|
122
|
+
const ramp = [
|
|
123
|
+
['ink', 900],
|
|
124
|
+
['ink-mute', 700],
|
|
125
|
+
['ink-soft', 500],
|
|
126
|
+
['ink-faint', 300]
|
|
127
|
+
].map(([name, shade]) => ({ name, c: (() => {
|
|
128
|
+
const tok = resolveToken(config, skin, 'ink', shade, name, mode)
|
|
129
|
+
return tok ? contrastRatio(tok, paper) : null
|
|
130
|
+
})() }))
|
|
131
|
+
|
|
132
|
+
// AA gate on the readable text tones.
|
|
133
|
+
for (const { name, c } of ramp) {
|
|
134
|
+
if ((name === 'ink' || name === 'ink-mute') && c !== null && c < AA) {
|
|
135
|
+
findings.push({
|
|
136
|
+
id: `contrast-${name}-${mode}`,
|
|
137
|
+
label: `${name} on paper clears AA in ${mode}`,
|
|
138
|
+
status: 'warn',
|
|
139
|
+
fixable: false,
|
|
140
|
+
fix: `${name} vs paper is ${c.toFixed(2)}:1 in ${mode} (need ≥ ${AA}). It backs ${name === 'ink' ? 'primary' : 'secondary'} text — darken ${name} (or lighten paper) in this mode.`
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Monotonic ramp: ink > ink-mute > ink-soft > ink-faint.
|
|
146
|
+
const seq = ramp.filter((r) => r.c !== null)
|
|
147
|
+
for (let i = 1; i < seq.length; i++) {
|
|
148
|
+
if (seq[i].c > seq[i - 1].c + 0.01) {
|
|
149
|
+
findings.push({
|
|
150
|
+
id: `contrast-ramp-${mode}`,
|
|
151
|
+
label: `ink ramp is monotonic in ${mode}`,
|
|
152
|
+
status: 'warn',
|
|
153
|
+
fixable: false,
|
|
154
|
+
fix: `${seq[i].name} (${seq[i].c.toFixed(2)}:1) has MORE contrast than ${seq[i - 1].name} (${seq[i - 1].c.toFixed(2)}:1) in ${mode} — the ramp ink > ink-mute > ink-soft > ink-faint is inverted/compressed.`
|
|
155
|
+
})
|
|
156
|
+
break
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Border visibility: paper-edge must be distinguishable from paper.
|
|
161
|
+
const edge = resolveToken(config, skin, 'surface', 400, 'paper-edge', mode)
|
|
162
|
+
if (edge) {
|
|
163
|
+
const ec = contrastRatio(edge, paper)
|
|
164
|
+
if (ec < 1.18) {
|
|
165
|
+
findings.push({
|
|
166
|
+
id: `contrast-paper-edge-${mode}`,
|
|
167
|
+
label: `paper-edge is visible against paper in ${mode}`,
|
|
168
|
+
status: 'warn',
|
|
169
|
+
fixable: false,
|
|
170
|
+
fix: `paper-edge vs paper is only ${ec.toFixed(2)}:1 in ${mode} — the hairline border is effectively invisible. Pick a paper-edge value with more lightness separation from the canvas in this mode.`
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return findings
|
|
176
|
+
}
|
package/src/doctor.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* eslint-disable no-console */
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
3
|
-
import { resolve } from 'path'
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs'
|
|
3
|
+
import { resolve, join } from 'path'
|
|
4
4
|
import { loadConfig as loadParsedConfig } from './config.js'
|
|
5
5
|
import {
|
|
6
6
|
generateUnoConfig,
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
serializeRokkitConfig
|
|
11
11
|
} from './init.js'
|
|
12
12
|
import { themeInitScript } from '@rokkit/unocss/hooks'
|
|
13
|
+
import { checkContrastTokens } from './contrast.js'
|
|
13
14
|
|
|
14
15
|
const KNOWN_THEMES = [
|
|
15
16
|
'rokkit',
|
|
@@ -169,6 +170,26 @@ export function runChecks(fs) {
|
|
|
169
170
|
]
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
const SKIP_DIRS = new Set(['node_modules', '.svelte-kit', 'dist', '.git', 'coverage'])
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Recursively list absolute file paths under `dir`, skipping vendored/build dirs.
|
|
177
|
+
* @param {string} dir
|
|
178
|
+
* @returns {string[]}
|
|
179
|
+
*/
|
|
180
|
+
function listFiles(dir) {
|
|
181
|
+
const out = []
|
|
182
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
183
|
+
if (entry.isDirectory()) {
|
|
184
|
+
if (SKIP_DIRS.has(entry.name)) continue
|
|
185
|
+
out.push(...listFiles(join(dir, entry.name)))
|
|
186
|
+
} else {
|
|
187
|
+
out.push(join(dir, entry.name))
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return out
|
|
191
|
+
}
|
|
192
|
+
|
|
172
193
|
/**
|
|
173
194
|
* Create a real filesystem adapter rooted at cwd.
|
|
174
195
|
* @param {string} cwd
|
|
@@ -177,7 +198,8 @@ function createFsAdapter(cwd) {
|
|
|
177
198
|
return {
|
|
178
199
|
exists: (p) => existsSync(p),
|
|
179
200
|
read: (p) => readFileSync(p, 'utf-8'),
|
|
180
|
-
resolve: (p) => resolve(cwd, p)
|
|
201
|
+
resolve: (p) => resolve(cwd, p),
|
|
202
|
+
list: (dir) => listFiles(dir)
|
|
181
203
|
}
|
|
182
204
|
}
|
|
183
205
|
|
|
@@ -408,6 +430,49 @@ export function validateConfigShape(config) {
|
|
|
408
430
|
return checks
|
|
409
431
|
}
|
|
410
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Patterns that flag a surface/border token being used as a text colour.
|
|
435
|
+
* paper-edge / paper-mute are border/surface tones — used as text they're faint
|
|
436
|
+
* in light and invisible in dark. paper / paper-soft are legitimate on-colours.
|
|
437
|
+
*/
|
|
438
|
+
const TEXT_MISUSE_PATTERNS = [
|
|
439
|
+
/text-paper-edge\b/,
|
|
440
|
+
/text-paper-mute\b/,
|
|
441
|
+
/color:\s*var\(\s*--paper-edge\s*\)/,
|
|
442
|
+
/color:\s*var\(\s*--paper-mute\s*\)/
|
|
443
|
+
]
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Advisory lint: scan src/ for surface/border tokens used as text colour.
|
|
447
|
+
* @param {{ list?: (dir: string) => string[], exists: (p: string) => boolean, read: (p: string) => string, resolve: (p: string) => string }} fs
|
|
448
|
+
* @returns {Array<{ id: string, label: string, status: 'warn', fixable: false, fix: string }>}
|
|
449
|
+
*/
|
|
450
|
+
export function checkTextTokenUsage(fs) {
|
|
451
|
+
if (typeof fs.list !== 'function' || !fs.exists(fs.resolve('src'))) return []
|
|
452
|
+
|
|
453
|
+
const cwd = process.cwd()
|
|
454
|
+
const hits = []
|
|
455
|
+
for (const file of fs.list(fs.resolve('src'))) {
|
|
456
|
+
if (!file.endsWith('.css') && !file.endsWith('.svelte')) continue
|
|
457
|
+
const lines = fs.read(file).split('\n')
|
|
458
|
+
const display = file.startsWith(cwd) ? file.slice(cwd.length).replace(/^\//, '') : file
|
|
459
|
+
lines.forEach((line, i) => {
|
|
460
|
+
if (TEXT_MISUSE_PATTERNS.some((re) => re.test(line))) hits.push(`${display}:${i + 1}`)
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (hits.length === 0) return []
|
|
465
|
+
return [
|
|
466
|
+
{
|
|
467
|
+
id: 'surface-token-as-text',
|
|
468
|
+
label: 'no surface/border token used as text colour',
|
|
469
|
+
status: 'warn',
|
|
470
|
+
fixable: false,
|
|
471
|
+
fix: `paper-edge / paper-mute are border/surface tokens — never use them for text (faint in light, invisible in dark). Use the ink scale (ink-mute for readable secondary text). Found at:\n ${hits.join('\n ')}`
|
|
472
|
+
}
|
|
473
|
+
]
|
|
474
|
+
}
|
|
475
|
+
|
|
411
476
|
/**
|
|
412
477
|
* Interactive doctor command — validates project setup.
|
|
413
478
|
* @param {{ fix?: boolean }} [opts]
|
|
@@ -431,6 +496,32 @@ export async function doctor(opts = {}) {
|
|
|
431
496
|
}
|
|
432
497
|
}
|
|
433
498
|
|
|
499
|
+
const contrastChecks = checkContrastTokens(parsed)
|
|
500
|
+
/* v8 ignore start -- loadConfig uses a dynamic file:// import which always fails in
|
|
501
|
+
JSDOM, so parsed is null and contrastChecks is [] → this print block is unreachable
|
|
502
|
+
in tests. The check logic itself is covered directly in contrast.spec.js. */
|
|
503
|
+
if (contrastChecks.length > 0) {
|
|
504
|
+
console.info('\nContrast (WCAG AA · light + dark):')
|
|
505
|
+
for (const c of contrastChecks) {
|
|
506
|
+
console.info(` WARN ${c.label}`)
|
|
507
|
+
console.info(` ${c.fix}`)
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/* v8 ignore stop */
|
|
511
|
+
|
|
512
|
+
const usageChecks = checkTextTokenUsage(createFsAdapter(cwd))
|
|
513
|
+
/* v8 ignore start -- this print block depends on the real filesystem walk finding
|
|
514
|
+
a misused token in cwd; in the JSDOM doctor() test the temp project is clean, so
|
|
515
|
+
usageChecks is [] and this is unreachable. checkTextTokenUsage is covered directly. */
|
|
516
|
+
if (usageChecks.length > 0) {
|
|
517
|
+
console.info('\nToken usage:')
|
|
518
|
+
for (const c of usageChecks) {
|
|
519
|
+
console.info(` WARN ${c.label}`)
|
|
520
|
+
console.info(` ${c.fix}`)
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/* v8 ignore stop */
|
|
524
|
+
|
|
434
525
|
handleResults(checks, cwd, failures, opts.fix ?? false)
|
|
435
526
|
console.info('')
|
|
436
527
|
}
|