Implement Dokio screens from the design doc (turns 3 + 4)

Scaffold an Expo + TypeScript app for Dokio, the app that scans
Behördenpost and explains it in plain language, and build the four
screens specified in "Dokio Design.dc.html":

  4a  Onboarding, full-bleed dither gradient
  4b  Home, dithered scan card
  3a  Kamera, mocked viewfinder
  3b  Archiv, search + filters + month groups

Design tokens from the turn 4 blue palette live in src/theme.ts.
Content is mocked in src/data/letters.ts, taken verbatim from the doc.

DitherBackground is a GLSL port of the doc's dither-bg.js, which drew a
low-resolution canvas and upscaled it with image-rendering: pixelated;
the fragment shader snaps to the same grid instead. Two notes on the
port:

- The original's fract(sin(x) * 43758.5453) hash only distributes well
  in float64. A fragment shader's highp float has ~24 mantissa bits, so
  it collapsed to a near-constant field and the gradient bands rendered
  straight rather than warped. Replaced with Hoskins' hash, verified
  against the original running side by side.
- The 4x4 Bayer matrix is built recursively rather than as a lookup,
  which avoids dynamic array indexing; the construction reproduces all
  16 entries of the original exactly.

The camera screen deliberately stops short of expo-camera: the
viewfinder is a static stand-in, so the flow is navigable without
permissions. The 2a result screen is not part of turns 3 and 4 and is
not included.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 14:48:50 +02:00
commit 66bdf646ae
28 changed files with 9526 additions and 0 deletions

View File

@@ -0,0 +1,237 @@
import { GLView } from 'expo-gl';
import type { ExpoWebGLRenderingContext } from 'expo-gl';
import { useCallback, useEffect, useRef } from 'react';
import { PixelRatio, StyleSheet, View, type ViewProps } from 'react-native';
/**
* GPU port of `dither-bg.js` from the design doc: a diagonal gradient broken
* into four palette steps, ordered-dithered with a 4x4 Bayer matrix, drifting
* slowly under value noise, and faded to white in the bottom-left corner.
*
* The original rendered a low-resolution canvas and upscaled it with
* `image-rendering: pixelated`. Here the fragment shader snaps to the same
* low-resolution grid, so the chunky pixels survive without a second surface.
*/
const VERT = `
attribute vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
`;
const FRAG = `
precision highp float;
uniform vec2 uResolution;
uniform float uTime;
uniform float uPixel;
uniform vec3 uPal0;
uniform vec3 uPal1;
uniform vec3 uPal2;
uniform vec3 uPal3;
// The original used fract(sin(...) * 43758.5453), which only distributes well
// in float64. A fragment shader's highp float has ~24 bits of mantissa, so that
// hash collapses to a near-constant field and the gradient bands come out
// straight instead of warped. This is Hoskins' hash — same [0,1) distribution,
// no huge intermediate magnitudes.
float hash(float x, float y) {
vec3 p = fract(vec3(x, y, x) * 0.1031);
p += dot(p, p.yzx + 33.33);
return fract((p.x + p.y) * p.z);
}
float sstep(float t) {
return t * t * (3.0 - 2.0 * t);
}
// Bilinear value noise, matching the JS vnoise().
float vnoise(float x, float y) {
float xi = floor(x);
float yi = floor(y);
float u = sstep(x - xi);
float v = sstep(y - yi);
float a = hash(xi, yi);
float b = hash(xi + 1.0, yi);
float c = hash(xi, yi + 1.0);
float d = hash(xi + 1.0, yi + 1.0);
float top = a + (b - a) * u;
float bottom = c + (d - c) * u;
return top + (bottom - top) * v;
}
// Recursive construction of the 4x4 Bayer matrix, normalised to [0,1).
// Produces exactly BAYER[(y%4)*4 + (x%4)] / 16 from the original script.
float bayer2(vec2 a) {
a = floor(a);
return fract(a.x * 0.5 + a.y * a.y * 0.75);
}
float bayer4(vec2 a) {
return bayer2(a * 0.5) * 0.25 + bayer2(a);
}
void main() {
// Snap to the low-resolution grid the original canvas drew on.
vec2 grid = max(floor(uResolution / uPixel), vec2(2.0));
vec2 cell = floor(gl_FragCoord.xy / uPixel);
float u = cell.x / grid.x;
// The JS loop walks rows top-down with v = 1 - y/h; gl_FragCoord.y counts up.
float v = cell.y / grid.y;
float rowFromTop = grid.y - 1.0 - cell.y;
float n = (vnoise(u * 1.5 + uTime * 0.05, v * 1.5 + uTime * 0.03) * 2.0 - 1.0) * 0.25;
float g = (u + v) * 0.5 * 1.2 + n;
float ci = step(0.3, g) + step(0.55, g) + step(0.8, g);
float dither = bayer4(vec2(cell.x, rowFromTop));
float thr = fract(g * 4.0);
// Nudge up one palette step where the dither threshold allows it.
ci = min(ci + step(ci, 2.5) * step(dither * 0.5, thr), 3.0);
vec3 col = mix(mix(mix(uPal0, uPal1, step(0.5, ci)), uPal2, step(1.5, ci)), uPal3, step(2.5, ci));
float fade = clamp(length(vec2(u, v)) / 0.25, 0.0, 1.0);
float f = sstep(fade);
gl_FragColor = vec4(mix(vec3(1.0), col, f), 1.0);
}
`;
function hexToRgb(hex: string): [number, number, number] {
let h = hex.replace('#', '');
if (h.length === 3) {
h = h
.split('')
.map((c) => c + c)
.join('');
}
return [
parseInt(h.slice(0, 2), 16) / 255,
parseInt(h.slice(2, 4), 16) / 255,
parseInt(h.slice(4, 6), 16) / 255,
];
}
function mix(
a: [number, number, number],
b: [number, number, number],
t: number,
): [number, number, number] {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function compile(gl: ExpoWebGLRenderingContext, type: number, source: string) {
const shader = gl.createShader(type)!;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
throw new Error(`dither shader: ${gl.getShaderInfoLog(shader)}`);
}
return shader;
}
export type DitherBackgroundProps = {
/** Deep end of the gradient. */
color1?: string;
/** Pale end of the gradient. */
color2?: string;
/** Animation rate multiplier. */
speed?: number;
/** Size of one dither cell in points. */
pixel?: number;
style?: ViewProps['style'];
};
export function DitherBackground({
color1 = '#2340D6',
color2 = '#EAF2FF',
speed = 1,
pixel = 3,
style,
}: DitherBackgroundProps) {
// Keep the latest props in a ref so the render loop never needs restarting.
const params = useRef({ color1, color2, speed, pixel });
params.current = { color1, color2, speed, pixel };
const frame = useRef<number | null>(null);
useEffect(() => {
return () => {
if (frame.current !== null) cancelAnimationFrame(frame.current);
};
}, []);
const onContextCreate = useCallback((gl: ExpoWebGLRenderingContext) => {
const program = gl.createProgram()!;
gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERT));
gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(program);
gl.useProgram(program);
// Full-screen triangle pair.
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
gl.STATIC_DRAW,
);
const position = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
const uResolution = gl.getUniformLocation(program, 'uResolution');
const uTime = gl.getUniformLocation(program, 'uTime');
const uPixel = gl.getUniformLocation(program, 'uPixel');
const uPal = [0, 1, 2, 3].map((i) => gl.getUniformLocation(program, `uPal${i}`));
const { drawingBufferWidth: width, drawingBufferHeight: height } = gl;
gl.viewport(0, 0, width, height);
gl.uniform2f(uResolution, width, height);
// The design's `pixel` is in points; scale it into framebuffer pixels so
// the cells stay the same visual size on a retina screen.
const density = PixelRatio.get();
let lastColors = '';
const start = Date.now();
let lastDraw = 0;
const render = () => {
frame.current = requestAnimationFrame(render);
const now = Date.now();
// The original throttled itself to ~25fps; the drift is slow enough that
// anything faster is wasted work.
if (now - lastDraw < 40) return;
lastDraw = now;
const p = params.current;
const key = `${p.color1}|${p.color2}`;
if (key !== lastColors) {
lastColors = key;
const deep = hexToRgb(p.color1);
const pale = hexToRgb(p.color2);
const palette = [deep, mix(deep, pale, 0.33), mix(deep, pale, 0.66), pale];
palette.forEach((c, i) => gl.uniform3f(uPal[i], c[0], c[1], c[2]));
}
gl.uniform1f(uPixel, Math.max(1, p.pixel * density));
gl.uniform1f(uTime, ((now - start) / 1000) * p.speed);
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.endFrameEXP();
};
render();
}, []);
return (
<View style={[StyleSheet.absoluteFill, style]} pointerEvents="none">
<GLView style={StyleSheet.absoluteFill} onContextCreate={onContextCreate} />
</View>
);
}

122
src/components/Icons.tsx Normal file
View File

@@ -0,0 +1,122 @@
import Svg, { Circle, Path, Rect } from 'react-native-svg';
/** Icon set traced from the inline SVGs in the design doc. */
type IconProps = {
size?: number;
color?: string;
/** Some icons are drawn heavier in the design (the check is 2.4). */
strokeWidth?: number;
};
export function CameraIcon({ size = 24, color = '#F2F6FE', strokeWidth = 2 }: IconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Rect
x={2.5}
y={6.5}
width={19}
height={13}
rx={4}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
<Circle cx={12} cy={13} r={3.6} stroke={color} strokeWidth={strokeWidth} />
<Path
d="M8.5 6.5l1.4-2h4.2l1.4 2"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
</Svg>
);
}
export function EnvelopeIcon({ size = 18, color = '#2340D6', strokeWidth = 2 }: IconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Rect
x={3}
y={5}
width={18}
height={14}
rx={3}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
<Path
d="M3.5 7l8.5 6 8.5-6"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}
export function CheckIcon({ size = 18, color = '#7C93C9', strokeWidth = 2.4 }: IconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path
d="M4 12.5l5 5L20 6.5"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}
export function SearchIcon({ size = 17, color = '#6B7A99', strokeWidth = 2 }: IconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Circle cx={11} cy={11} r={7} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
<Path d="M20 20l-3.5-3.5" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
</Svg>
);
}
export function FlashIcon({ size = 16, color = '#F2F6FE', strokeWidth = 2 }: IconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path
d="M13 3L5 13h5l-1 8 8-10h-5l1-8z"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}
export function GalleryIcon({ size = 20, color = '#F2F6FE', strokeWidth = 2 }: IconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Rect
x={3}
y={3}
width={18}
height={18}
rx={4}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
<Circle cx={9} cy={9} r={2} stroke={color} strokeWidth={strokeWidth} />
<Path
d="M21 15l-5-4-9 8"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}

View File

@@ -0,0 +1,82 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors, font, radius, shadow } from '../theme';
import type { Letter } from '../data/letters';
import { CheckIcon, EnvelopeIcon } from './Icons';
type Props = {
letter: Letter;
/** The archive shows "Sender · Kind" and a date prefix; Home shows neither. */
variant?: 'home' | 'archive';
onPress?: () => void;
};
export function LetterRow({ letter, variant = 'home', onPress }: Props) {
const needsAction = letter.deadlineDays !== undefined;
const title =
variant === 'archive' && letter.kind ? `${letter.sender} · ${letter.kind}` : letter.sender;
const subtitle =
variant === 'archive' ? `${letter.date} · ${letter.summary}` : letter.summary;
const badge =
variant === 'archive'
? needsAction
? `${letter.deadlineDays} Tage`
: null
: needsAction
? 'Frist'
: null;
return (
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.pressed]}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={`${title}. ${subtitle}`}
>
<View style={[styles.tile, { backgroundColor: needsAction ? colors.tintStrong : colors.tint }]}>
{needsAction ? <EnvelopeIcon color={colors.blue} /> : <CheckIcon />}
</View>
<View style={styles.text}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.subtitle}>{subtitle}</Text>
</View>
{badge ? (
<View style={styles.badge}>
<Text style={styles.badgeText}>{badge}</Text>
</View>
) : null}
</Pressable>
);
}
const styles = StyleSheet.create({
row: {
backgroundColor: colors.surface,
borderRadius: radius.xl,
paddingVertical: 14,
paddingHorizontal: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
...shadow.card,
},
pressed: { opacity: 0.7 },
tile: {
width: 40,
height: 40,
borderRadius: radius.md,
alignItems: 'center',
justifyContent: 'center',
},
text: { flex: 1 },
title: { fontFamily: font.extrabold, fontSize: 14.5, color: colors.ink },
subtitle: { fontFamily: font.semibold, fontSize: 12, color: colors.slate, marginTop: 1 },
badge: {
backgroundColor: colors.tintStrong,
borderRadius: radius.pill,
paddingVertical: 5,
paddingHorizontal: 10,
},
badgeText: { fontFamily: font.extrabold, fontSize: 11, color: colors.blueInk },
});

95
src/data/letters.ts Normal file
View File

@@ -0,0 +1,95 @@
/** Mock content, lifted verbatim from the design doc so the screens read the same. */
export type Letter = {
id: string;
/** Sender, e.g. "Jobcenter". */
sender: string;
/** Document kind shown after the sender in the archive, e.g. "Bescheid". */
kind?: string;
/** Plain-language one-liner. */
summary: string;
/** Date label used in the archive list. */
date: string;
/** Month bucket the archive groups by. */
month: string;
/** Days left on the deadline, if the letter has one. */
deadlineDays?: number;
/** Whether anything is still expected of the user. */
done: boolean;
};
export const letters: Letter[] = [
{
id: 'jobcenter-bescheid',
sender: 'Jobcenter',
kind: 'Bescheid',
summary: 'Du bekommst mehr Geld',
date: 'Heute',
month: 'JULI 2026',
deadlineDays: 8,
done: false,
},
{
id: 'finanzamt-info',
sender: 'Finanzamt',
kind: 'Info',
summary: 'Nur zur Info nichts zu tun',
date: '12. Juli',
month: 'JULI 2026',
done: true,
},
{
id: 'stadtwerke-rechnung',
sender: 'Stadtwerke',
kind: 'Rechnung',
summary: 'Bezahlt',
date: '4. Juli',
month: 'JULI 2026',
done: true,
},
{
id: 'krankenkasse-brief',
sender: 'Krankenkasse',
kind: 'Brief',
summary: 'Karte kam per Post',
date: '28. Juni',
month: 'JUNI 2026',
done: true,
},
{
id: 'vermieter-schreiben',
sender: 'Vermieter',
kind: 'Schreiben',
summary: 'Nebenkosten erklärt',
date: '15. Juni',
month: 'JUNI 2026',
done: true,
},
];
/** Archive list grouped into the month headings the design shows. */
export function lettersByMonth(): { month: string; items: Letter[] }[] {
const groups: { month: string; items: Letter[] }[] = [];
for (const letter of letters) {
const last = groups[groups.length - 1];
if (last && last.month === letter.month) last.items.push(letter);
else groups.push({ month: letter.month, items: [letter] });
}
return groups;
}
export const archiveFilters = ['Alle', 'Mit Frist', 'Erledigt', 'Ämter'] as const;
export type ArchiveFilter = (typeof archiveFilters)[number];
export function filterLetters(filter: ArchiveFilter): Letter[] {
switch (filter) {
case 'Mit Frist':
return letters.filter((l) => l.deadlineDays !== undefined);
case 'Erledigt':
return letters.filter((l) => l.done);
case 'Ämter':
return letters.filter((l) => ['Jobcenter', 'Finanzamt'].includes(l.sender));
default:
return letters;
}
}

137
src/theme.ts Normal file
View File

@@ -0,0 +1,137 @@
/**
* Design tokens for Dokio, extracted from "Dokio Design.dc.html" (turns 3 + 4).
* Turn 4 introduced the deeper blue (#2340D6) for dithered surfaces; flat blue
* surfaces from turn 3 keep the lighter #3B5BE8.
*/
export const colors = {
/** Dithered surfaces — the deep end of the gradient. */
blueDeep: '#2340D6',
/** Flat blue surfaces: nav pill, filter chips, primary buttons. */
blue: '#3B5BE8',
/** Text on light backgrounds when it needs to read as blue. */
blueInk: '#2743CF',
/** Pale end of the dither gradient on the onboarding screen. */
blueMist: '#B9CFF9',
/** Pale end of the dither gradient inside the home scan card. */
blueHaze: '#7D9BF0',
/** Secondary numeral in stat cards. */
blueSoft: '#8AA3EE',
/** Camera-screen accent: corner brackets. */
blueBright: '#6C8CFF',
/** App background. */
canvas: '#EEF3FC',
surface: '#FFFFFF',
/** Tinted icon tile / badge background. */
tintStrong: '#DCE7FB',
/** Neutral icon tile background for "nothing to do" rows. */
tint: '#E6EEFB',
/** Inactive progress-bar segment. */
track: '#D6DFF3',
ink: '#22304F',
inkMuted: '#44547A',
/** Secondary copy. */
slate: '#6B7A99',
/** Placeholder text and inactive tab labels. */
slateLight: '#9FAECB',
/** Muted check icons. */
slateIcon: '#7C93C9',
/** Deadline sub-copy inside the result card. */
slateBlue: '#5A6FA8',
/** Text on blue. */
onBlue: '#F2F6FE',
/** Camera screen. */
night: '#141B2E',
viewfinderFrom: '#3A4258',
viewfinderTo: '#262E44',
/** "Brief erkannt" status dot. */
green: '#5BE884',
/** Paper mock inside the viewfinder. */
paper: '#E9E5DC',
paperInkStrong: '#C8C2B4',
paperInk: '#D5D0C4',
} as const;
export const font = {
/** Headings — Bricolage Grotesque, always weight 800 in the design. */
display: 'BricolageGrotesque_800ExtraBold',
/** Body — Nunito Sans. */
regular: 'NunitoSans_400Regular',
semibold: 'NunitoSans_600SemiBold',
bold: 'NunitoSans_700Bold',
extrabold: 'NunitoSans_800ExtraBold',
} as const;
export const radius = {
sm: 6,
md: 14,
lg: 16,
xl: 18,
'2xl': 20,
'3xl': 24,
'4xl': 28,
'5xl': 32,
pill: 99,
} as const;
export const space = {
/** Horizontal page gutter used by every screen. */
gutter: 24,
} as const;
/** Card elevation used across list rows and stat tiles. */
export const shadow = {
card: {
shadowColor: '#22304F',
shadowOpacity: 0.06,
shadowRadius: 8,
shadowOffset: { width: 0, height: 2 },
elevation: 2,
},
cardSoft: {
shadowColor: '#22304F',
shadowOpacity: 0.07,
shadowRadius: 10,
shadowOffset: { width: 0, height: 2 },
elevation: 3,
},
/** The blue scan card on Home. */
blueCard: {
shadowColor: '#2340D6',
shadowOpacity: 0.32,
shadowRadius: 26,
shadowOffset: { width: 0, height: 10 },
elevation: 10,
},
/** Floating scan button in the tab bar. */
fab: {
shadowColor: '#3B5BE8',
shadowOpacity: 0.4,
shadowRadius: 16,
shadowOffset: { width: 0, height: 6 },
elevation: 8,
},
/** Tab bar lip. */
tabBar: {
shadowColor: '#22304F',
shadowOpacity: 0.06,
shadowRadius: 16,
shadowOffset: { width: 0, height: -4 },
elevation: 12,
},
} as const;
/**
* The design mocks white text over the dither gradient with a soft glow so it
* stays legible against the pale end of the ramp.
*/
export const glow = {
textShadowColor: 'rgba(16,30,90,0.35)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 10,
} as const;