Skip to content
Merged
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ aggregate instead: an italic *Catalog* line at the end of the version section an

### Added

- **Product/UX audit 2026-07-08** (`agentic/audits/2026-07-08-product-ux.md`) — 8-auditor
workflow run scoped to pipeline, rating criteria, tabs, and product qualities; Health Score 43,
headlined by a critical crawler outage (every bot UA gets 502 from the `@seo_proxy` hop) and
quality-score calibration drift (#9616).
- **Legal page "other projects" block** — below the disclaimer, cross-linking the maintainer's
other projects kurrentschrift.ink and cite-citadel, tracked as `external_link` with
destinations `kurrentschrift` / `cite_citadel`; deliberately without `noreferrer` so the
target sites' analytics can attribute the traffic (#9616).

- **Project skill layer under `.claude/skills/`** — six skills, ported from the kurrentschrift
and cite-citadel setups and adapted to anyplot: `verify-frontend` (browser-drive changed flows,
both viewports × both themes, cloud playwright-core fallback probe), `verify-api` (read sweep +
Expand Down Expand Up @@ -55,6 +64,13 @@ aggregate instead: an italic *Catalog* line at the end of the version section an
- **Catalog curation: 13 interactive-first specs removed** — plot types whose value is inherently
interactive don't fit a static-first catalog (#8645).

### Fixed

- **All react-refresh ESLint warnings resolved** — `PALETTE` / `snippet()` extracted from
`PalettePage.tsx` into `PalettePage.helpers.ts` (following the `MapPage.helpers.ts` pattern),
and the `react-refresh/only-export-components` rule scoped off for test modules
(`*.test.{ts,tsx}`, `test-utils.tsx`), which never participate in Fast Refresh (#9616).

## [3.0.0] — 2026-06-10 — Julia & JavaScript, 15 libraries & the Imprint palette

anyplot 3.0 doubles the language count — Julia and JavaScript join Python and R, growing the
Expand Down
135 changes: 135 additions & 0 deletions agentic/audits/2026-07-08-product-ux.md

Large diffs are not rendered by default.

227 changes: 101 additions & 126 deletions agentic/audits/latest.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions app/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ export default [
'perfectionist/sort-named-imports': ['error', { type: 'natural' }],
},
},
{
// Test modules never participate in Fast Refresh — the rule only guards
// HMR boundaries of the running app, so it has no meaning here.
files: ['src/**/*.test.{ts,tsx}', 'src/test-utils.tsx'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
{
ignores: ['dist/**', 'node_modules/**', 'coverage/**', '*.config.js', '*.config.mjs'],
},
Expand Down
25 changes: 25 additions & 0 deletions app/src/pages/LegalPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,31 @@ describe('LegalPage', () => {
expect(screen.getByText('~$34/month')).toBeInTheDocument();
});

it('renders other projects links without noreferrer', () => {
render(<LegalPage />);

const kurrentLink = screen.getByRole('link', { name: 'kurrentschrift.ink' });
expect(kurrentLink).toHaveAttribute('href', 'https://kurrentschrift.ink');
expect(kurrentLink).toHaveAttribute('target', '_blank');
expect(kurrentLink).toHaveAttribute('rel', 'noopener');

const citadelLink = screen.getByRole('link', { name: 'cite-citadel' });
expect(citadelLink).toHaveAttribute('href', 'https://github.com/MarkusNeusinger/cite-citadel');
expect(citadelLink).toHaveAttribute('target', '_blank');
Comment thread
MarkusNeusinger marked this conversation as resolved.
expect(citadelLink).toHaveAttribute('rel', 'noopener');
});

it('tracks external link clicks for other projects', () => {
trackEvent.mockClear();
render(<LegalPage />);

fireEvent.click(screen.getByRole('link', { name: 'kurrentschrift.ink' }));
fireEvent.click(screen.getByRole('link', { name: 'cite-citadel' }));

expect(trackEvent).toHaveBeenCalledWith('external_link', { destination: 'kurrentschrift' });
expect(trackEvent).toHaveBeenCalledWith('external_link', { destination: 'cite_citadel' });
});

it('tracks external link clicks for linkedin, x and github', () => {
trackEvent.mockClear();
render(<LegalPage />);
Expand Down
33 changes: 32 additions & 1 deletion app/src/pages/LegalPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,36 @@ export function LegalPage() {
</>
);

const otherProjectsBlock = (
<>
<Typography sx={subheadingStyle}>other projects</Typography>
<Typography sx={textStyle}>
{/* no noreferrer — own sites, keep the referrer for their analytics */}
<Link
href="https://kurrentschrift.ink"
target="_blank"
rel="noopener"
onClick={() => trackEvent('external_link', { destination: 'kurrentschrift' })}
sx={proseLinkStyle}
>
kurrentschrift.ink
</Link>{' '}
— read and write old german cursive
<br />
<Link
href="https://github.com/MarkusNeusinger/cite-citadel"
target="_blank"
Comment thread
MarkusNeusinger marked this conversation as resolved.
rel="noopener"
onClick={() => trackEvent('external_link', { destination: 'cite_citadel' })}
sx={proseLinkStyle}
>
cite-citadel
</Link>{' '}
— llm-maintained, fully-cited personal wiki
</Typography>
</>
);

const disclaimerBlock = (
<>
<Typography sx={subheadingStyle}>disclaimer</Typography>
Expand Down Expand Up @@ -148,6 +178,7 @@ export function LegalPage() {
<Box sx={{ flex: 1, minWidth: 0 }}>{operatorContactBlock}</Box>
</Box>
{disclaimerBlock}
{otherProjectsBlock}
</Box>
</Box>

Expand Down Expand Up @@ -523,7 +554,7 @@ export function LegalPage() {
mt: 2,
}}
>
last updated: April 2026
last updated: July 2026
</Typography>
</Box>
</>
Expand Down
251 changes: 251 additions & 0 deletions app/src/pages/PalettePage.helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
/**
* Helpers for the /palette page: the imprint palette data (8 categorical
* hues with OKLCH coords + ΔE / WCAG stats) and the per-language copy-paste
* snippet generator. Pure data + string building, extracted from
* PalettePage.tsx so the page module only exports the component (Fast
* Refresh) and snippet() stays unit-testable in PalettePage.snippet.test.ts.
*/

export type Swatch = {
hex: string;
name: string;
family: string;
role: string;
L: number;
C: number;
H: number;
oklch: string;
/** Per-hex min ΔE to any other imprint member under normal vision. */
minNorm: number;
/** Per-hex min ΔE to any other imprint member under worst CVD sim (deuter / protan / tritan). */
minCvd: number;
/** WCAG contrast on the cream page bg #F5F3EC (--bg-page). */
wcagL: number;
/** WCAG contrast on warm near-black bg #121210. */
wcagD: number;
};

export const PALETTE: Swatch[] = [
{
hex: '#009E73',
name: 'brand green',
family: 'green',
role: 'first series',
L: 0.62,
C: 0.13,
H: 165.5,
oklch: 'oklch(0.620 0.130 165.5)',
minNorm: 22.51,
minCvd: 13.7,
wcagL: 3.08,
wcagD: 5.48,
},
{
hex: '#C475FD',
name: 'lavender',
family: 'purple',
role: 'creative',
L: 0.704,
C: 0.202,
H: 308.9,
oklch: 'oklch(0.704 0.202 308.9)',
minNorm: 30.03,
minCvd: 13.81,
wcagL: 2.59,
wcagD: 6.53,
},
{
hex: '#4467A3',
name: 'blue',
family: 'blue',
role: 'cool / info',
L: 0.516,
C: 0.104,
H: 260.6,
oklch: 'oklch(0.516 0.104 260.6)',
minNorm: 32.35,
minCvd: 10.7,
wcagL: 5.09,
wcagD: 3.32,
},
{
hex: '#BD8233',
name: 'ochre',
family: 'yellow',
role: 'earth / commodity',
L: 0.652,
C: 0.118,
H: 70.5,
oklch: 'oklch(0.652 0.118 70.5)',
minNorm: 24.0,
minCvd: 8.81,
wcagL: 2.95,
wcagD: 5.72,
},
{
hex: '#AE3030',
name: 'matte red',
family: 'red',
role: 'bad / loss / error',
L: 0.503,
C: 0.163,
H: 25.2,
oklch: 'oklch(0.503 0.163 25.2)',
minNorm: 20.73,
minCvd: 15.2,
wcagL: 5.79,
wcagD: 2.92,
},
{
hex: '#2ABCCD',
name: 'cyan',
family: 'cyan',
role: 'sky / tech-cool',
L: 0.73,
C: 0.117,
H: 207.1,
oklch: 'oklch(0.730 0.117 207.1)',
minNorm: 22.51,
minCvd: 13.7,
wcagL: 2.06,
wcagD: 8.19,
},
{
hex: '#954477',
name: 'rose',
family: 'purple',
role: 'wellness / health',
L: 0.508,
C: 0.125,
H: 343.9,
oklch: 'oklch(0.508 0.125 343.9)',
minNorm: 20.73,
minCvd: 10.7,
wcagL: 5.61,
wcagD: 3.01,
},
{
hex: '#99B314',
name: 'lime',
family: 'green',
role: 'growth / nature',
L: 0.722,
C: 0.167,
H: 119.8,
oklch: 'oklch(0.722 0.167 119.8)',
minNorm: 24.0,
minCvd: 8.81,
wcagL: 2.15,
wcagD: 7.87,
},
];

export type Lang = 'python' | 'r' | 'julia' | 'js';

export function snippet(lang: Lang, oklch: boolean, sortedPalette: Swatch[]): string {
// CSS `oklch()` literals are only consumable where the runtime parses CSS
// colours (JS / CSS). matplotlib, ggplot2 and Makie can't read them, so those
// snippets stay hex (runnable) and surface the OKLCH coordinate as a trailing
// comment instead — toggling OKLCH then annotates rather than breaks the code.
const useLiteral = oklch && lang === 'js';
const colorVal = (s: Swatch) => (useLiteral ? s.oklch : s.hex);
const hueComment = (s: Swatch) => (oklch && lang !== 'js' ? ` # ${s.oklch}` : '');
const amberVal = useLiteral ? 'oklch(0.841 0.108 98.3)' : '#DDCC77';
const seqStart = useLiteral ? 'oklch(0.620 0.130 165.5)' : '#009E73';
const seqEnd = useLiteral ? 'oklch(0.516 0.104 260.6)' : '#4467A3';
const divStart = useLiteral ? 'oklch(0.503 0.163 25.2)' : '#AE3030';
const divEnd = useLiteral ? 'oklch(0.516 0.104 260.6)' : '#4467A3';
// Theme-adaptive neutrals + diverging midpoints are kept as hex in both
// modes — they are structural near-greys, and converting them to OKLCH adds
// noise without helping anyone paste them.
switch (lang) {
case 'python':
return `from types import SimpleNamespace

# imprint — anyplot's palette (https://anyplot.ai/palette)
IMPRINT = SimpleNamespace(
hues=[
${sortedPalette.map(s => ` "${s.hex}",${hueComment(s)}`).join('\n')}
],
amber="${amberVal}", # warning / caution
neutral=SimpleNamespace(light="#1A1A17", dark="#F0EFE8"),
muted=SimpleNamespace(light="#6B6A63", dark="#A8A79F"),
seq=["${seqStart}", "${seqEnd}"], # sequential: green -> blue
div=SimpleNamespace( # diverging: red <-> midpoint <-> blue
light=["${divStart}", "#FAF8F1", "${divEnd}"],
dark=["${divStart}", "#1A1A17", "${divEnd}"],
),
)

color = IMPRINT.hues[0] # first series is ALWAYS slot 0 (brand green)

# continuous data — build matplotlib cmaps from the stops
from matplotlib.colors import LinearSegmentedColormap
imprint_seq = LinearSegmentedColormap.from_list("imprint_seq", IMPRINT.seq)
imprint_div = LinearSegmentedColormap.from_list("imprint_div", IMPRINT.div.light) # .dark on dark bg`;
case 'r':
return `# imprint — anyplot's palette (https://anyplot.ai/palette)
IMPRINT <- list(
hues = c(
${sortedPalette.map((s, i, arr) => ` "${s.hex}"${i < arr.length - 1 ? ',' : ''}${hueComment(s)}`).join('\n')}
),
amber = "${amberVal}", # warning / caution
neutral = list(light = "#1A1A17", dark = "#F0EFE8"),
muted = list(light = "#6B6A63", dark = "#A8A79F"),
seq = c("${seqStart}", "${seqEnd}"), # sequential: green -> blue
div = list( # diverging: red <-> midpoint <-> blue
light = c("${divStart}", "#FAF8F1", "${divEnd}"),
dark = c("${divStart}", "#1A1A17", "${divEnd}")
)
)

color <- IMPRINT$hues[1] # first series = slot 0, brand green (R is 1-based: index 1)

# continuous data — ggplot2 gradient scales
scale_color_gradientn(colours = IMPRINT$seq) # sequential
scale_color_gradient2(low = IMPRINT$div$light[1], mid = IMPRINT$div$light[2], high = IMPRINT$div$light[3], midpoint = 0) # diverging — 0 maps to the neutral midpoint (light theme)`;
case 'julia':
return `using Colors # colorant"#..."

# imprint — anyplot's palette (https://anyplot.ai/palette)
const IMPRINT = (
hues = [
${sortedPalette.map(s => ` colorant"${s.hex}",${hueComment(s)}`).join('\n')}
],
amber = colorant"${amberVal}", # warning / caution
neutral = (light = colorant"#1A1A17", dark = colorant"#F0EFE8"),
muted = (light = colorant"#6B6A63", dark = colorant"#A8A79F"),
seq = [colorant"${seqStart}", colorant"${seqEnd}"],
div = (light = [colorant"${divStart}", colorant"#FAF8F1", colorant"${divEnd}"],
dark = [colorant"${divStart}", colorant"#1A1A17", colorant"${divEnd}"]),
)

color = IMPRINT.hues[1] # first series = slot 0, brand green (Julia is 1-based: index 1)

# continuous data — Makie / ColorSchemes
using ColorSchemes
imprint_seq = cgrad(IMPRINT.seq)
imprint_div = cgrad(IMPRINT.div.light) # .dark on dark bg`;
case 'js':
return `// imprint — anyplot's palette (https://anyplot.ai/palette)
const IMPRINT = {
hues: [
${sortedPalette.map(s => ` "${colorVal(s)}",`).join('\n')}
],
amber: "${amberVal}", // warning / caution
neutral: { light: "#1A1A17", dark: "#F0EFE8" },
muted: { light: "#6B6A63", dark: "#A8A79F" },
seq: ["${seqStart}", "${seqEnd}"], // sequential: green -> blue
div: { // diverging: red <-> midpoint <-> blue
light: ["${divStart}", "#FAF8F1", "${divEnd}"],
dark: ["${divStart}", "#1A1A17", "${divEnd}"],
},
};

const color = IMPRINT.hues[0]; // first series is ALWAYS slot 0 (brand green)

// continuous data — [stop, color] pairs for your charting lib
const imprintSeq = IMPRINT.seq.map((c, i, a) => [i / (a.length - 1), c]);
const imprintDiv = IMPRINT.div.light.map((c, i, a) => [i / (a.length - 1), c]);`;
}
}
2 changes: 1 addition & 1 deletion app/src/pages/PalettePage.snippet.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';

import { type Lang, PALETTE, snippet } from 'src/pages/PalettePage';
import { type Lang, PALETTE, snippet } from 'src/pages/PalettePage.helpers';

const LANGS: Lang[] = ['python', 'r', 'julia', 'js'];

Expand Down
Loading
Loading