Compare commits
10 Commits
fc3c0659b2
...
192d629125
| Author | SHA1 | Date | |
|---|---|---|---|
| 192d629125 | |||
| 1a1f1f1938 | |||
| 93051021fc | |||
| a52cb9f84b | |||
| 06ebef80c1 | |||
| ef5bc9c3a6 | |||
| ac113f23c7 | |||
| 4ec108484e | |||
| a7df2d0037 | |||
| f7f7e0db8c |
@@ -1,134 +0,0 @@
|
|||||||
{
|
|
||||||
"iterations": [
|
|
||||||
{
|
|
||||||
"iteration": 1,
|
|
||||||
"startedAt": "2026-02-11T01:23:53.316Z",
|
|
||||||
"endedAt": "2026-02-11T01:35:05.771Z",
|
|
||||||
"durationMs": 668162,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/components/PMRInterface.tsx",
|
|
||||||
"src/components/views/SummaryView.tsx"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 2,
|
|
||||||
"startedAt": "2026-02-11T01:35:10.653Z",
|
|
||||||
"endedAt": "2026-02-11T01:41:17.649Z",
|
|
||||||
"durationMs": 362952,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/components/PMRInterface.tsx",
|
|
||||||
"src/components/views/ConsultationsView.tsx"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 3,
|
|
||||||
"startedAt": "2026-02-11T01:41:22.593Z",
|
|
||||||
"endedAt": "2026-02-11T01:42:12.448Z",
|
|
||||||
"durationMs": 45945,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 4,
|
|
||||||
"startedAt": "2026-02-11T01:42:17.819Z",
|
|
||||||
"endedAt": "2026-02-11T01:50:07.083Z",
|
|
||||||
"durationMs": 465353,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/components/PMRInterface.tsx",
|
|
||||||
"src/components/views/MedicationsView.tsx",
|
|
||||||
"src/index.css"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 5,
|
|
||||||
"startedAt": "2026-02-11T01:50:12.105Z",
|
|
||||||
"endedAt": "2026-02-11T01:58:48.582Z",
|
|
||||||
"durationMs": 512760,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/components/PMRInterface.tsx",
|
|
||||||
"src/components/views/ProblemsView.tsx"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 6,
|
|
||||||
"startedAt": "2026-02-11T01:58:53.428Z",
|
|
||||||
"endedAt": "2026-02-11T02:05:52.941Z",
|
|
||||||
"durationMs": 415696,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/components/PMRInterface.tsx",
|
|
||||||
"src/components/views/InvestigationsView.tsx"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 7,
|
|
||||||
"startedAt": "2026-02-11T02:05:57.951Z",
|
|
||||||
"endedAt": "2026-02-11T02:16:56.192Z",
|
|
||||||
"durationMs": 654352,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/components/PMRInterface.tsx",
|
|
||||||
"src/components/views/DocumentsView.tsx"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 8,
|
|
||||||
"startedAt": "2026-02-11T02:17:01.348Z",
|
|
||||||
"endedAt": "2026-02-11T02:30:01.815Z",
|
|
||||||
"durationMs": 776565,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/components/PMRInterface.tsx",
|
|
||||||
"src/components/views/ReferralsView.tsx"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"totalDurationMs": 3901785,
|
|
||||||
"struggleIndicators": {
|
|
||||||
"repeatedErrors": {},
|
|
||||||
"noProgressIterations": 0,
|
|
||||||
"shortIterations": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
+494
@@ -0,0 +1,494 @@
|
|||||||
|
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
|
||||||
|
|
||||||
|
// ─── Heartbeat generation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function generateHeartbeatPoints(
|
||||||
|
amplitude: number,
|
||||||
|
): { x: number; y: number }[] {
|
||||||
|
const points: { x: number; y: number }[] = [];
|
||||||
|
const steps = 200;
|
||||||
|
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const t = i / steps;
|
||||||
|
let y = 0;
|
||||||
|
|
||||||
|
if (t >= 0.05 && t < 0.2) {
|
||||||
|
const pt = (t - 0.05) / 0.15;
|
||||||
|
y = 0.12 * Math.sin(pt * Math.PI);
|
||||||
|
} else if (t >= 0.25 && t < 0.32) {
|
||||||
|
const pt = (t - 0.25) / 0.07;
|
||||||
|
y = -0.1 * Math.sin(pt * Math.PI);
|
||||||
|
} else if (t >= 0.32 && t < 0.42) {
|
||||||
|
const pt = (t - 0.32) / 0.1;
|
||||||
|
y = 1.0 * Math.sin(pt * Math.PI);
|
||||||
|
} else if (t >= 0.42 && t < 0.5) {
|
||||||
|
const pt = (t - 0.42) / 0.08;
|
||||||
|
y = -0.25 * Math.sin(pt * Math.PI);
|
||||||
|
} else if (t >= 0.55 && t < 0.75) {
|
||||||
|
const pt = (t - 0.55) / 0.2;
|
||||||
|
y = 0.2 * Math.sin(pt * Math.PI);
|
||||||
|
}
|
||||||
|
|
||||||
|
points.push({ x: t, y: y * amplitude });
|
||||||
|
}
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Beat = { startFrame: number; widthPx: number; amplitude: number };
|
||||||
|
|
||||||
|
function buildBeats(fps: number): Beat[] {
|
||||||
|
const beats: Beat[] = [];
|
||||||
|
beats.push({ startFrame: Math.round(0.6 * fps), widthPx: 60, amplitude: 0.25 });
|
||||||
|
beats.push({ startFrame: Math.round(1.4 * fps), widthPx: 80, amplitude: 0.45 });
|
||||||
|
beats.push({ startFrame: Math.round(2.3 * fps), widthPx: 120, amplitude: 0.85 });
|
||||||
|
const normalStart = 3.2;
|
||||||
|
for (let i = 0; i < 1; i++) {
|
||||||
|
beats.push({
|
||||||
|
startFrame: Math.round((normalStart + i) * fps),
|
||||||
|
widthPx: 140,
|
||||||
|
amplitude: 1.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return beats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Letter definitions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const LETTERS: Record<string, { x: number; y: number }[]> = {
|
||||||
|
A: [
|
||||||
|
{ x: 0, y: 0 }, { x: 0.48, y: 1 }, { x: 0.53, y: 0.42 },
|
||||||
|
{ x: 0.6, y: 0.42 }, { x: 1, y: 0 },
|
||||||
|
],
|
||||||
|
N: [
|
||||||
|
{ x: 0, y: 0 }, { x: 0.12, y: 1 }, { x: 0.72, y: 0 },
|
||||||
|
{ x: 0.88, y: 1 }, { x: 1, y: 0 },
|
||||||
|
],
|
||||||
|
D: [
|
||||||
|
{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.5, y: 1 },
|
||||||
|
{ x: 0.85, y: 0.55 }, { x: 1, y: 0 },
|
||||||
|
],
|
||||||
|
R: [
|
||||||
|
{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.35, y: 1 },
|
||||||
|
{ x: 0.5, y: 0.6 }, { x: 0.55, y: 0.45 }, { x: 1, y: 0 },
|
||||||
|
],
|
||||||
|
E: [
|
||||||
|
{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.4, y: 1 },
|
||||||
|
{ x: 0.45, y: 0.5 }, { x: 0.65, y: 0.5 }, { x: 0.7, y: 0 },
|
||||||
|
{ x: 1, y: 0 },
|
||||||
|
],
|
||||||
|
W: [
|
||||||
|
{ x: 0, y: 0 }, { x: 0.05, y: 1 }, { x: 0.27, y: 0 },
|
||||||
|
{ x: 0.5, y: 0.65 }, { x: 0.73, y: 0 }, { x: 0.95, y: 1 },
|
||||||
|
{ x: 1, y: 0 },
|
||||||
|
],
|
||||||
|
C: [
|
||||||
|
{ x: 0, y: 0 }, { x: 0.08, y: 0.6 }, { x: 0.18, y: 1 },
|
||||||
|
{ x: 0.6, y: 1 }, { x: 0.8, y: 0.5 }, { x: 0.95, y: 0.1 },
|
||||||
|
{ x: 1, y: 0 },
|
||||||
|
],
|
||||||
|
H: [
|
||||||
|
{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.18, y: 0.5 },
|
||||||
|
{ x: 0.82, y: 0.5 }, { x: 0.9, y: 1 }, { x: 1, y: 0 },
|
||||||
|
],
|
||||||
|
L: [
|
||||||
|
{ x: 0, y: 0 }, { x: 0.12, y: 1 }, { x: 0.3, y: 1 },
|
||||||
|
{ x: 0.38, y: 0 }, { x: 1, y: 0 },
|
||||||
|
],
|
||||||
|
O: [
|
||||||
|
{ x: 0, y: 0 }, { x: 0.2, y: 0.85 }, { x: 0.35, y: 1 },
|
||||||
|
{ x: 0.65, y: 1 }, { x: 0.8, y: 0.85 }, { x: 1, y: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function interpolateLetterY(
|
||||||
|
points: { x: number; y: number }[],
|
||||||
|
t: number,
|
||||||
|
): number {
|
||||||
|
if (t <= points[0].x) return points[0].y;
|
||||||
|
if (t >= points[points.length - 1].x) return points[points.length - 1].y;
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
if (t >= points[i].x && t <= points[i + 1].x) {
|
||||||
|
const segT = (t - points[i].x) / (points[i + 1].x - points[i].x);
|
||||||
|
return points[i].y + (points[i + 1].y - points[i].y) * segT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Text layout ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TEXT = "ANDREW CHARLWOOD";
|
||||||
|
const LETTER_WIDTH = 72;
|
||||||
|
const LETTER_GAP = 10;
|
||||||
|
const SPACE_WIDTH = 30;
|
||||||
|
const BASE_LEFT_INSET = 9;
|
||||||
|
const BASE_RIGHT_INSET = 0;
|
||||||
|
|
||||||
|
type LetterLayout = {
|
||||||
|
char: string;
|
||||||
|
startX: number;
|
||||||
|
endX: number;
|
||||||
|
startConnector: number;
|
||||||
|
endConnector: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConnectorProfile = { leftInset: number; rightInset: number };
|
||||||
|
|
||||||
|
const CONNECTOR_PROFILES: Record<string, ConnectorProfile> = {
|
||||||
|
C: { leftInset: 20, rightInset: 8 },
|
||||||
|
O: { leftInset: 17, rightInset: 7 },
|
||||||
|
D: { leftInset: 0, rightInset: 13 },
|
||||||
|
L: { leftInset: 5, rightInset: 0 },
|
||||||
|
E: { leftInset: 5, rightInset: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_PROFILE: ConnectorProfile = { leftInset: 0, rightInset: 0 };
|
||||||
|
|
||||||
|
function layoutText(offsetX: number): LetterLayout[] {
|
||||||
|
const layout: LetterLayout[] = [];
|
||||||
|
let cursor = offsetX;
|
||||||
|
|
||||||
|
for (const char of TEXT) {
|
||||||
|
if (char === " ") {
|
||||||
|
cursor += SPACE_WIDTH;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const profile = CONNECTOR_PROFILES[char] ?? DEFAULT_PROFILE;
|
||||||
|
const startX = cursor;
|
||||||
|
const endX = cursor + LETTER_WIDTH;
|
||||||
|
layout.push({
|
||||||
|
char,
|
||||||
|
startX,
|
||||||
|
endX,
|
||||||
|
startConnector: startX + BASE_LEFT_INSET + profile.leftInset,
|
||||||
|
endConnector: endX - BASE_RIGHT_INSET - profile.rightInset,
|
||||||
|
});
|
||||||
|
cursor += LETTER_WIDTH + LETTER_GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextTotalWidth(): number {
|
||||||
|
return (
|
||||||
|
TEXT.replace(/ /g, "").length * (LETTER_WIDTH + LETTER_GAP) -
|
||||||
|
LETTER_GAP +
|
||||||
|
(TEXT.split(" ").length - 1) * SPACE_WIDTH
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Timing constants ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TRACE_SPEED = 350;
|
||||||
|
const HEAD_SCREEN_RATIO = 1;
|
||||||
|
const FLAT_GAP_SECONDS = 0.5;
|
||||||
|
const HOLD_SECONDS = 1.25;
|
||||||
|
const COMP_FPS = 60;
|
||||||
|
|
||||||
|
// How long the dot/line takes to exit the right side after text finishes
|
||||||
|
const EXIT_SECONDS = 1.5;
|
||||||
|
|
||||||
|
// Pre-compute duration for export
|
||||||
|
const _beats = buildBeats(COMP_FPS);
|
||||||
|
const _lastBeat = _beats[_beats.length - 1];
|
||||||
|
const _lastBeatEndWX = (_lastBeat.startFrame / COMP_FPS) * TRACE_SPEED + _lastBeat.widthPx;
|
||||||
|
const _textStartWX = _lastBeatEndWX + FLAT_GAP_SECONDS * TRACE_SPEED;
|
||||||
|
const _totalTextW = getTextTotalWidth();
|
||||||
|
const _textEndWX = _textStartWX + _totalTextW;
|
||||||
|
const _textEndFrame = Math.round((_textEndWX / TRACE_SPEED) * COMP_FPS);
|
||||||
|
|
||||||
|
export const ECGCOMBINED_DURATION = _textEndFrame + Math.round(HOLD_SECONDS * COMP_FPS) + Math.round(EXIT_SECONDS * COMP_FPS);
|
||||||
|
|
||||||
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ECGCombined = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps, width, height } = useVideoConfig();
|
||||||
|
|
||||||
|
const baselineY = height * 0.5;
|
||||||
|
const lineColor = "#00ff41";
|
||||||
|
const ecgMaxDeflection = height * 0.28;
|
||||||
|
const textMaxDeflection = height * 0.09;
|
||||||
|
const beats = buildBeats(fps);
|
||||||
|
|
||||||
|
// ── World-space text position ──
|
||||||
|
const lastBeat = beats[beats.length - 1];
|
||||||
|
const lastBeatEndWorldX = (lastBeat.startFrame / fps) * TRACE_SPEED + lastBeat.widthPx;
|
||||||
|
const textStartWorldX = lastBeatEndWorldX + FLAT_GAP_SECONDS * TRACE_SPEED;
|
||||||
|
const totalTextWidth = getTextTotalWidth();
|
||||||
|
const textEndWorldX = textStartWorldX + totalTextWidth;
|
||||||
|
const textLayout = layoutText(textStartWorldX); // world-space positions
|
||||||
|
|
||||||
|
// ── Final screen position: text centered when done ──
|
||||||
|
const desiredTextStartScreen = (width - totalTextWidth) / 2;
|
||||||
|
const finalHeadScreenX = desiredTextStartScreen + totalTextWidth;
|
||||||
|
const headScreenDuringEcg = HEAD_SCREEN_RATIO * width;
|
||||||
|
|
||||||
|
// ── Head position (world space, keeps moving past text) ──
|
||||||
|
const currentTime = frame / fps;
|
||||||
|
const headX = currentTime * TRACE_SPEED;
|
||||||
|
const textEndFrame = Math.round((textEndWorldX / TRACE_SPEED) * fps);
|
||||||
|
const isTextPhase = headX > textStartWorldX;
|
||||||
|
const isTextDone = frame >= textEndFrame - 3;
|
||||||
|
|
||||||
|
// ── Viewport: keeps scrolling, head drifts from 75% → right edge ──
|
||||||
|
let headScreenX: number;
|
||||||
|
let viewOffset: number;
|
||||||
|
|
||||||
|
if (headX <= textStartWorldX) {
|
||||||
|
viewOffset = Math.max(0, headX - headScreenDuringEcg);
|
||||||
|
headScreenX = headX - viewOffset;
|
||||||
|
} else if (headX >= textEndWorldX) {
|
||||||
|
// Lock viewport so text stays centered; dot keeps moving right
|
||||||
|
viewOffset = textEndWorldX - finalHeadScreenX;
|
||||||
|
headScreenX = headX - viewOffset;
|
||||||
|
} else {
|
||||||
|
const p = (headX - textStartWorldX) / (textEndWorldX - textStartWorldX);
|
||||||
|
headScreenX = headScreenDuringEcg + p * (finalHeadScreenX - headScreenDuringEcg);
|
||||||
|
viewOffset = headX - headScreenX;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Y function (world space) ──
|
||||||
|
function getYAtX(worldX: number): number {
|
||||||
|
for (const beat of beats) {
|
||||||
|
const beatStartX = (beat.startFrame / fps) * TRACE_SPEED;
|
||||||
|
const beatEndX = beatStartX + beat.widthPx;
|
||||||
|
if (worldX >= beatStartX && worldX <= beatEndX) {
|
||||||
|
const progress = (worldX - beatStartX) / beat.widthPx;
|
||||||
|
const beatPoints = generateHeartbeatPoints(beat.amplitude);
|
||||||
|
const idx = Math.min(
|
||||||
|
Math.floor(progress * (beatPoints.length - 1)),
|
||||||
|
beatPoints.length - 1,
|
||||||
|
);
|
||||||
|
return baselineY - beatPoints[idx].y * ecgMaxDeflection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const item of textLayout) {
|
||||||
|
if (worldX >= item.startX && worldX <= item.endX) {
|
||||||
|
const t = (worldX - item.startX) / (item.endX - item.startX);
|
||||||
|
const letterDef = LETTERS[item.char];
|
||||||
|
if (letterDef) {
|
||||||
|
return baselineY - interpolateLetterY(letterDef, t) * textMaxDeflection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return baselineY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ECG trace path (up to text start) ──
|
||||||
|
const firstBeatWorldX = (beats[0].startFrame / fps) * TRACE_SPEED;
|
||||||
|
const traceStartWX = Math.max(Math.floor(firstBeatWorldX), Math.floor(viewOffset));
|
||||||
|
const ecgTraceEndWX = Math.min(
|
||||||
|
Math.ceil(headX),
|
||||||
|
Math.ceil(textStartWorldX),
|
||||||
|
Math.ceil(viewOffset + width),
|
||||||
|
);
|
||||||
|
|
||||||
|
const traceSegments: string[] = [];
|
||||||
|
if (ecgTraceEndWX >= traceStartWX) {
|
||||||
|
for (let wx = traceStartWX; wx <= ecgTraceEndWX; wx++) {
|
||||||
|
const sx = wx - viewOffset;
|
||||||
|
const y = getYAtX(wx);
|
||||||
|
traceSegments.push(wx === traceStartWX ? `M ${sx} ${y}` : `L ${sx} ${y}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tracePathD = traceSegments.join(" ");
|
||||||
|
|
||||||
|
// ── Flat exit line after text finishes ──
|
||||||
|
let exitPathD = "";
|
||||||
|
if (isTextDone && headX > textEndWorldX) {
|
||||||
|
const exitStartSX = textEndWorldX - viewOffset - 32;
|
||||||
|
const exitEndSX = headX - viewOffset;
|
||||||
|
exitPathD = `M ${exitStartSX} ${baselineY} L ${exitEndSX} ${baselineY}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Neon fade ──
|
||||||
|
const neonLengthPx = 200;
|
||||||
|
const neonFadeScreenEnd = headScreenX;
|
||||||
|
const neonFadeScreenStart = neonFadeScreenEnd - neonLengthPx;
|
||||||
|
|
||||||
|
// ── Text mask ──
|
||||||
|
const maskBrushSize = 1;
|
||||||
|
const clipLeadPx = 20;
|
||||||
|
const blockUnmaskDelay = 15;
|
||||||
|
const blockFeatherPx = 10;
|
||||||
|
|
||||||
|
const textMaskEndSX = isTextPhase
|
||||||
|
? (isTextDone ? width : Math.max(0, Math.min(Math.ceil(headScreenX), width)))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const textMaskSegments: string[] = [];
|
||||||
|
if (isTextPhase && textMaskEndSX > 0 && !isTextDone) {
|
||||||
|
for (let sx = 0; sx <= textMaskEndSX; sx++) {
|
||||||
|
const y = getYAtX(viewOffset + sx);
|
||||||
|
textMaskSegments.push(sx === 0 ? `M ${sx} ${y}` : `L ${sx} ${y}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const textMaskPathD = textMaskSegments.join(" ");
|
||||||
|
const blockUnmaskX = isTextDone ? width : Math.max(0, textMaskEndSX - blockUnmaskDelay);
|
||||||
|
|
||||||
|
// ── Connectors (screen space) ──
|
||||||
|
const connectorSegments: string[] = [];
|
||||||
|
for (let i = 0; i < textLayout.length - 1; i++) {
|
||||||
|
const curr = textLayout[i];
|
||||||
|
const next = textLayout[i + 1];
|
||||||
|
connectorSegments.push(
|
||||||
|
`M ${curr.endConnector - viewOffset - 18} ${baselineY} L ${next.startConnector - viewOffset} ${baselineY}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const connectorPathD = connectorSegments.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ backgroundColor: "#000000", overflow: "hidden" }}>
|
||||||
|
<svg width={width} height={height} style={{ position: "absolute", top: 0, left: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<filter id="neon" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="2" result="blur1" />
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="6" result="blur2" />
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="14" result="blur3" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur3" />
|
||||||
|
<feMergeNode in="blur2" />
|
||||||
|
<feMergeNode in="blur1" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<filter id="neonText" x="-30%" y="-30%" width="160%" height="160%">
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="tblur1" />
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="8" result="tblur2" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="tblur2" />
|
||||||
|
<feMergeNode in="tblur1" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<linearGradient
|
||||||
|
id="neonMaskGrad"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1={neonFadeScreenStart} y1={0}
|
||||||
|
x2={neonFadeScreenEnd} y2={0}
|
||||||
|
>
|
||||||
|
<stop offset="0%" stopColor="black" />
|
||||||
|
<stop offset="100%" stopColor="white" />
|
||||||
|
</linearGradient>
|
||||||
|
<mask id="neonMask">
|
||||||
|
<rect x={0} y={0} width={width} height={height} fill="url(#neonMaskGrad)" />
|
||||||
|
</mask>
|
||||||
|
<clipPath id="textReveal">
|
||||||
|
<rect
|
||||||
|
x={0} y={0}
|
||||||
|
width={isTextDone ? width : Math.max(0, headScreenX + clipLeadPx)}
|
||||||
|
height={height}
|
||||||
|
/>
|
||||||
|
</clipPath>
|
||||||
|
<linearGradient
|
||||||
|
id="blockUnmaskGrad"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1={blockUnmaskX - blockFeatherPx} y1={0}
|
||||||
|
x2={blockUnmaskX} y2={0}
|
||||||
|
>
|
||||||
|
<stop offset="0%" stopColor="white" />
|
||||||
|
<stop offset="100%" stopColor="black" />
|
||||||
|
</linearGradient>
|
||||||
|
<mask id="textWipeMask">
|
||||||
|
<rect x={0} y={0} width={width} height={height} fill={isTextDone ? "white" : "black"} />
|
||||||
|
{!isTextDone && blockUnmaskX > 0 && (
|
||||||
|
<rect x={0} y={0} width={blockUnmaskX} height={height} fill="url(#blockUnmaskGrad)" />
|
||||||
|
)}
|
||||||
|
{!isTextDone && textMaskPathD && (
|
||||||
|
<path
|
||||||
|
d={textMaskPathD}
|
||||||
|
fill="none"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth={15 * maskBrushSize}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
filter="url(#neonText)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</mask>
|
||||||
|
<radialGradient id="headGlow" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stopColor="#ffffff" stopOpacity={0.8} />
|
||||||
|
<stop offset="30%" stopColor={lineColor} stopOpacity={0.6} />
|
||||||
|
<stop offset="100%" stopColor={lineColor} stopOpacity={0} />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* ECG trace */}
|
||||||
|
{tracePathD && (
|
||||||
|
<g>
|
||||||
|
<path d={tracePathD} fill="none" stroke={lineColor} strokeWidth={2}
|
||||||
|
strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
<path d={tracePathD} fill="none" stroke={lineColor} strokeWidth={2.5}
|
||||||
|
strokeLinejoin="round" strokeLinecap="round"
|
||||||
|
filter="url(#neon)" mask="url(#neonMask)" />
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Text + connectors */}
|
||||||
|
{isTextPhase && (
|
||||||
|
<g clipPath="url(#textReveal)">
|
||||||
|
<g mask="url(#textWipeMask)">
|
||||||
|
{textLayout.map((item, i) => (
|
||||||
|
<text
|
||||||
|
key={i}
|
||||||
|
x={(item.startX + item.endX) / 2 - viewOffset}
|
||||||
|
y={baselineY}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="alphabetic"
|
||||||
|
fontSize={Math.round(textMaxDeflection / 0.715)}
|
||||||
|
fontFamily="Arial, Helvetica, sans-serif"
|
||||||
|
fontWeight="bold"
|
||||||
|
fill="none"
|
||||||
|
stroke={lineColor}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
filter="url(#neonText)"
|
||||||
|
>
|
||||||
|
{item.char}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
{connectorPathD && (
|
||||||
|
<path d={connectorPathD} fill="none" stroke={lineColor}
|
||||||
|
strokeWidth={1.5} strokeLinecap="round" />
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Flat exit line after text */}
|
||||||
|
{exitPathD && (
|
||||||
|
<g>
|
||||||
|
<path d={exitPathD} fill="none" stroke={lineColor} strokeWidth={2}
|
||||||
|
strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
<path d={exitPathD} fill="none" stroke={lineColor} strokeWidth={2.5}
|
||||||
|
strokeLinejoin="round" strokeLinecap="round"
|
||||||
|
filter="url(#neon)" />
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Head dot */}
|
||||||
|
{headScreenX >= 0 && headScreenX <= width && (
|
||||||
|
<>
|
||||||
|
<circle cx={headScreenX} cy={getYAtX(headX)} r={20} fill="url(#headGlow)" />
|
||||||
|
<circle cx={headScreenX} cy={getYAtX(headX)} r={3} fill={lineColor} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Scanlines */}
|
||||||
|
<div style={{
|
||||||
|
position: "absolute", top: 0, left: 0, width: "100%", height: "100%",
|
||||||
|
background: "repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.08) 2px, rgba(0,0,0,0.08) 4px)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* Vignette */}
|
||||||
|
<div style={{
|
||||||
|
position: "absolute", top: 0, left: 0, width: "100%", height: "100%",
|
||||||
|
background: "radial-gradient(ellipse at center, transparent 60%, rgba(0,0,0,0.5) 100%)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}} />
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
+28
-141
@@ -1,168 +1,55 @@
|
|||||||
# Implementation Plan — Clinical Record PMR System
|
# Implementation Plan — Design 7: The Clinical Record (v2)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Transform the existing React CV application into a **Patient Medical Record (PMR) system** — a faithful digital clinical information system that presents Andy's CV as a clinician would view a patient record. This is Design 7: The Clinical Record, completely replacing the previous ECG-based design.
|
Redesign of the Clinical Record PMR interface to achieve **absolute thematic fidelity** to real NHS clinical software (EMIS Web, SystmOne). The codebase has existing components from a prior iteration that are functionally complete but lack the visual density, authentic clinical system feel, and design quality described in the specification. This plan rebuilds each visual component to production-showcase quality.
|
||||||
|
|
||||||
**Core Concept:**
|
**Core principle:** This is NOT a clinical "theme" loosely applied — it's a **faithful digital clinical information system**. Every element must look like it belongs in actual NHS software. Border-heavy, table-heavy, functional — zero decorative flourish.
|
||||||
The "patient" is Andy's career. Users navigate a genuine PMR interface (similar to EMIS Web, SystmOne, Vision) with:
|
|
||||||
- Patient banner with persistent demographic context
|
|
||||||
- Sidebar navigation with clinical record categories (Summary, Consultations, Medications, Problems, Investigations, Documents, Referrals)
|
|
||||||
- Consultation-journal format for experience (History/Examination/Plan structure)
|
|
||||||
- Tabular medications list for skills with proficiency "dosages"
|
|
||||||
- Clinical alert system for standout achievements
|
|
||||||
- Light-mode only (authentic to clinical systems)
|
|
||||||
- Border-heavy, table-heavy, functional aesthetic
|
|
||||||
|
|
||||||
**Key Features:**
|
**Data files are correct and complete** (`src/data/*`). The existing type system (`src/types/pmr.ts`) is sound. The phase management in `App.tsx` works. This plan focuses on **rebuilding the visual layer** to match the specification.
|
||||||
- ECG exit animation → Login sequence → PMR interface materialization (~2.7s total transition)
|
|
||||||
- Animated login screen with typing username/password
|
|
||||||
- 7 sidebar views with instant content swapping (authentic clinical system behavior)
|
|
||||||
- Expandable consultation entries with coded entries (SNOMED-style references)
|
|
||||||
- Sortable medications table with prescribing history expansion
|
|
||||||
- Traffic-light status system (green/amber/red/gray)
|
|
||||||
- Clinical alert banner with acknowledge interaction
|
|
||||||
- Responsive: desktop sidebar → tablet icon-only → mobile bottom nav
|
|
||||||
- Full keyboard navigation (Alt+1-7 shortcuts)
|
|
||||||
- Search across all PMR sections with fuse.js
|
|
||||||
|
|
||||||
**Tech Stack:**
|
|
||||||
- React 18+ with TypeScript
|
|
||||||
- Vite for build tooling
|
|
||||||
- Tailwind CSS for styling
|
|
||||||
- Framer Motion for login animation and transitions
|
|
||||||
- Lucide React for clinical icons
|
|
||||||
- fuse.js for fuzzy search
|
|
||||||
|
|
||||||
**Project Structure:**
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── components/
|
|
||||||
│ ├── BootSequence.tsx # Existing terminal animation (preserved)
|
|
||||||
│ ├── ECGAnimation.tsx # Modified for PMR transition
|
|
||||||
│ ├── LoginScreen.tsx # Animated login sequence
|
|
||||||
│ ├── PMRInterface.tsx # Main PMR layout container
|
|
||||||
│ ├── PatientBanner.tsx # Full + condensed banner
|
|
||||||
│ ├── ClinicalSidebar.tsx # Navigation sidebar
|
|
||||||
│ ├── ClinicalAlert.tsx # Dismissible alert banner
|
|
||||||
│ ├── Breadcrumb.tsx # Navigation breadcrumb
|
|
||||||
│ ├── views/
|
|
||||||
│ │ ├── SummaryView.tsx # Patient summary landing
|
|
||||||
│ │ ├── ConsultationsView.tsx # Experience as consultations
|
|
||||||
│ │ ├── MedicationsView.tsx # Skills as medications
|
|
||||||
│ │ ├── ProblemsView.tsx # Achievements as problems
|
|
||||||
│ │ ├── InvestigationsView.tsx# Projects as investigations
|
|
||||||
│ │ ├── DocumentsView.tsx # Education as documents
|
|
||||||
│ │ └── ReferralsView.tsx # Contact as referral form
|
|
||||||
│ ├── ui/
|
|
||||||
│ │ ├── ConsultationEntry.tsx # Expandable consultation
|
|
||||||
│ │ ├── MedicationTable.tsx # Sortable skills table
|
|
||||||
│ │ ├── ProblemEntry.tsx # Problem list item
|
|
||||||
│ │ ├── InvestigationEntry.tsx# Investigation result
|
|
||||||
│ │ └── DocumentEntry.tsx # Document list item
|
|
||||||
├── hooks/
|
|
||||||
│ ├── useScrollCondensation.ts # Patient banner scroll behavior
|
|
||||||
│ └── useSearch.ts # Fuse.js search hook
|
|
||||||
├── data/
|
|
||||||
│ ├── consultations.ts # Experience data
|
|
||||||
│ ├── medications.ts # Skills data
|
|
||||||
│ ├── problems.ts # Achievements data
|
|
||||||
│ ├── investigations.ts # Projects data
|
|
||||||
│ └── documents.ts # Education data
|
|
||||||
├── types/
|
|
||||||
│ └── pmr.ts # All PMR TypeScript interfaces
|
|
||||||
├── lib/
|
|
||||||
│ └── utils.ts # Utility functions
|
|
||||||
├── App.tsx # Phase manager (boot → ecg → login → pmr)
|
|
||||||
└── index.css # Tailwind + PMR CSS variables
|
|
||||||
```
|
|
||||||
|
|
||||||
**Reference Materials:**
|
|
||||||
- `designs/07-the-clinical-record.md` — Complete design specification
|
|
||||||
- `References/CV_v4.md` — Source CV content
|
|
||||||
- `References/concept.html` — Previous ECG implementation (timing reference only)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quality Checks
|
## Quality Checks
|
||||||
|
|
||||||
- `npm run dev` — Development server starts without errors
|
Run ALL of these after each task. All must pass before committing.
|
||||||
- `npm run build` — Production build completes without errors
|
|
||||||
- `npm run lint` — No ESLint errors
|
|
||||||
- `npm run typecheck` — No TypeScript errors
|
|
||||||
- Manual verification:
|
|
||||||
- Boot sequence plays (4s) → ECG flatlines → Login screen types username/password → PMR interface materializes
|
|
||||||
- Patient banner condenses on scroll (80px → 48px)
|
|
||||||
- All 7 sidebar views render correctly with proper data
|
|
||||||
- Consultation entries expand/collapse with History/Examination/Plan sections
|
|
||||||
- Medications table sorts correctly by all columns
|
|
||||||
- Clinical alert appears on Summary view and dismisses with animation
|
|
||||||
- Search finds content across all sections
|
|
||||||
- Keyboard shortcuts work (Alt+1-7)
|
|
||||||
- Responsive layouts work at 1024px, 768px, and 480px
|
|
||||||
- No console errors
|
|
||||||
- Accessibility: screen reader announces views, tables are navigable
|
|
||||||
|
|
||||||
---
|
- `npm run typecheck`
|
||||||
|
- `npm run lint`
|
||||||
|
- `npm run build`
|
||||||
|
|
||||||
|
## Reference Files
|
||||||
|
|
||||||
|
Each task below references a specific file in `Ralph/refs/` — read ONLY that file for the task context. Do NOT read goal.md directly (it's 1100+ lines and will overwhelm your context).
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [x] **Task 1: Create PMR data layer and TypeScript types**
|
- [x] **Task 1: Design system foundation and font setup.** Read `Ralph/refs/ref-design-system.md`. Audit and fix the Tailwind config (`tailwind.config.js`) and global CSS (`src/index.css`) to ensure ALL PMR color tokens, typography, and spacing match the design system spec exactly. Specific fixes needed: (a) Ensure Geist Mono font is loaded via Google Fonts or local import — currently the project uses Fira Code for monospace but the spec requires Geist Mono for coded entries, timestamps, and data values. (b) Verify all PMR color tokens exist in Tailwind config: main content `#F5F7FA`, cards `#FFFFFF`, sidebar `#1E293B`, patient banner `#334155`, NHS blue `#005EB8`, green `#22C55E`, amber `#F59E0B`, red `#EF4444`, text primary `#111827`, text secondary `#6B7280`. (c) Ensure border-radius defaults to 4px for cards/inputs (not 8px or 12px — clinical systems use minimal rounding). (d) Add a `.pmr-theme` class or CSS custom properties layer for PMR-specific tokens if not already present. (e) Verify Inter font is loaded and configured as the primary font family. Do NOT invoke /frontend-design for this task — it's pure configuration.
|
||||||
|
|
||||||
Create `src/types/pmr.ts` with interfaces for: `Patient`, `Consultation` (History/Examination/Plan/CodedEntries), `Medication` (with PrescribingHistory), `Problem` (status, code, outcome), `Investigation` (with results), `Document`, `ReferralForm`. Create `src/data/` directory with files: `consultations.ts` (5 roles from CV_v4.md mapped to consultation format), `medications.ts` (18 skills mapped to medication format with prescribing history), `problems.ts` (8-10 achievements with traffic light status), `investigations.ts` (4 projects with methodology/results), `documents.ts` (MPharm, Mary Seacole, A-Levels, Research). All data must match CV_v4.md exactly with specific numbers (£14.6M, 14,000 patients, etc.).
|
- [ ] **Task 1b: Rebuild boot sequence and ECG animation.** Read `Ralph/refs/ref-boot-ecg.md` and `Ralph/refs/ref-design-system.md`. Also read `ECGCombined.tsx` in the project root for the Remotion reference implementation of the mask-based text reveal. This task covers the full pre-login animation flow: (a) **Refactor BootSequence.tsx** — replace hardcoded HTML strings with a clean config-driven structure. Each line type (header, field, separator, module, ready) maps to a React component. Keep the same visual output: green-on-black terminal, Fira Code font, 220ms staggered line reveals, `#00ff41` bright green / `#3a6b45` dim green / `#00e5ff` cyan labels. (b) **Cursor → dot transition** — the blinking green cursor at the end of boot must smoothly morph into the ECG's glowing trace dot. Capture the cursor's screen position and pass it to ECGAnimation as a `startPosition` prop. The cursor stops blinking, transitions from block to circular glow (~300ms), then begins moving rightward as the ECG trace dot. (c) **ECG start sync** — ECGAnimation must start its trace from the cursor position (not the far left edge). The first beat begins after a flat gap from the cursor position. Shift the world-space origin so the trace starts where the cursor was. (d) **Mask-based text reveal** — adopt ECGCombined.tsx's technique where pre-rendered stroke-only text is revealed by a wipe mask following the trace head (instead of the current alpha fade approach). Keep the current character spacing (`LETTER_W`, `LETTER_G`, `SPACE_W`) and heartbeat waveform. Add connector lines between letters at baseline. (e) **Keep**: heartbeat shape, beat timing (0.3→0.55→0.85→1.0 amplitude), canvas rendering, viewport scrolling, flatline draw, scanlines, vignette, background transition to `#1E293B`. (f) Respect `prefers-reduced-motion` — with reduced motion, skip animation and show static final frame or jump to login.
|
||||||
|
|
||||||
- [x] **Task 2: Modify ECGAnimation for PMR flatline transition**
|
- [ ] **Task 2: Rebuild LoginScreen component.** Read `Ralph/refs/ref-transition-login.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/LoginScreen.tsx` to match the login sequence specification exactly: (a) Dark blue-gray `#1E293B` background. (b) White card: 320px wide, **12px border-radius** (exception to the 4px rule — login cards can be rounder), subtle shadow. (c) NHS-blue shield icon at top with "CareerRecord PMR" branding text. (d) Username field types `A.CHARLWOOD` at 30ms/char in **Geist Mono** font. (e) Password field fills 8 dots at 20ms/dot. (f) Blinking cursor (530ms interval) in active field. (g) "Log In" button: NHS blue `#005EB8`, full width, pressed state darkens to `#004494`. (h) After submit: card scales to 103% and fades out over 200ms. (i) Respect `prefers-reduced-motion`. The login must feel like actually logging into NHS software at 8am on a Monday.
|
||||||
|
|
||||||
Modify `src/components/ECGAnimation.tsx` to change the exit phase. Instead of fading to white and revealing the CV, the animation should: 1) Complete the name tracing as normal, 2) Hold for 300ms, 3) Draw a flatline extending rightward from the name over 300ms (patient monitor flatline visual), 4) Fade entire canvas to black over 200ms, 5) Transition background to dark blue-gray (#1E293B) over 200ms. Emit `onComplete` callback to trigger LoginScreen. Total ECG phase: ~5-6 seconds. Preserve all existing animation timing for heartbeats and letter tracing.
|
- [ ] **Task 3: Rebuild PatientBanner component.** Read `Ralph/refs/ref-banner-sidebar.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/PatientBanner.tsx` to match the specification exactly: (a) Full banner 80px: background `#334155`, bottom border `1px solid #475569`. Name in Inter 600 **20px** (not 18px), details in Inter 400 14px. Layout must match the ASCII art in the ref file — surname-first format "CHARLWOOD, Andrew (Mr)", DOB/NHS No/Address on second row, phone/email/buttons on third row. (b) Status: green dot + "Active" text. Badge: "Open to opportunities" as blue pill. (c) Action buttons: outlined rectangles with NHS blue text and 1px border, 4px radius. Hover fills with NHS blue bg + white text. (d) Condensed banner 48px: single line with name, NHS number, status, action buttons only. Triggers at 100px scroll via IntersectionObserver. Smooth 200ms height transition. (e) Mobile banner: minimal top bar `CHARLWOOD, A (Mr) | 2211810 | dot` with overflow "..." menu. NHS Number tooltip: "GPhC Registration Number".
|
||||||
|
|
||||||
- [x] **Task 3: Build LoginScreen component with typing animation**
|
- [ ] **Task 4: Rebuild ClinicalSidebar component.** Read `Ralph/refs/ref-banner-sidebar.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/ClinicalSidebar.tsx` to match the specification exactly: (a) 220px fixed width, `#1E293B` background, `1px solid #334155` right border. (b) Header: "CareerRecord PMR / v1.0.0" in Inter 500, 13px, white at 50% opacity. (c) Search input: "Search record..." placeholder, integrated in sidebar below header. (d) **Navigation labels use CV-friendly terms**: Summary, Experience, Skills, Achievements, Projects, Education, Contact (NOT clinical jargon like Consultations, Medications, etc.). Same Lucide icons as before. Items: 44px height, 16px left padding, icons 18px + labels Inter 500 14px. Exact states: default white/70%, hover white/100% + `rgba(255,255,255,0.08)` bg, active white/100% + 3px NHS blue left border + `rgba(255,255,255,0.12)` bg + Inter 600 bold. (e) Separator line between Summary and Experience. (f) Footer: "Session: A.CHARLWOOD / Logged in: [time]" in Inter 400, 11px, `#64748B`. Time updates on mount. (g) Tablet mode: 56px icon-only with tooltips. (h) URL hash routing (`#summary`, `#experience`, `#skills`, `#achievements`, `#projects`, `#education`, `#contact`). (i) Alt+1-7 keyboard shortcuts, arrow key navigation, "/" for search focus. (j) Update the ViewId type in `src/types/pmr.ts` if needed to match the new labels.
|
||||||
|
|
||||||
Create `src/components/LoginScreen.tsx`. Dark blue-gray background (#1E293B). Centered white login card (320px wide, 12px radius, subtle shadow). NHS-blue shield icon at top. Username field: types "A.CHARLWOOD" character-by-character (30ms per char, Geist Mono font). Password field: fills with 8 dots (20ms per dot). "Log In" button: NHS blue (#005EB8), full width. After 150ms pause, button shows pressed state (darkens, 100ms), then emits `onComplete` callback. Total login animation: ~1.2s. Respect `prefers-reduced-motion`: with reduced motion, username appears instantly and login completes in ~500ms.
|
- [ ] **Task 5: Rebuild PMRInterface layout and add Breadcrumb.** Read `Ralph/refs/ref-banner-sidebar.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/PMRInterface.tsx` to ensure correct layout composition: (a) Fixed sidebar (220px) + sticky patient banner + scrollable main content area with `#F5F7FA` background. (b) Main content padding: 24px. No max-width — content fills available space. (c) Create `src/components/Breadcrumb.tsx`: displays "Patient Record > [Current View]" at the top of the main content area (using CV-friendly view names: Experience, Skills, Achievements, etc.). When a consultation/skill/achievement is expanded, the breadcrumb deepens to show the item name. Styled in Inter 400, 13px, gray-400, with chevron separators. Clickable links navigate back. (d) Interface materialization animations: patient banner slides down (200ms ease-out), sidebar slides from left (250ms ease-out, 50ms delay), content fades in (300ms, 100ms delay after sidebar). (e) View switching must be INSTANT — no crossfade or slide between views. (f) Ensure mobile layout uses bottom nav bar (56px height with safe area padding). (g) Update the view switching logic to use the new CV-friendly ViewId values (summary, experience, skills, achievements, projects, education, contact).
|
||||||
|
|
||||||
- [x] **Task 4: Build PatientBanner component (full and condensed)**
|
- [ ] **Task 6: Rebuild SummaryView with Clinical Alert.** Read `Ralph/refs/ref-summary-alert.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/views/SummaryView.tsx` and the clinical alert: (a) Clinical Alert: amber `#FEF3C7` background, 4px left border `#F59E0B`, text `#92400E`. Slides down with **spring animation** (not ease-out — use Framer Motion `type: "spring"` with appropriate damping). Acknowledge button: amber outlined. On click: warning icon cross-fades to green checkmark (200ms), holds 200ms, then alert height collapses (200ms ease-out). (b) 2-column card grid on desktop, single column on mobile. Cards have: header bar with title in Inter 600, 14px, uppercase, on `#F9FAFB` background with `1px solid #E5E7EB` bottom border. Card body: 16px padding, `1px solid #E5E7EB` border, 4px radius. (c) Demographics card: two-column key-value layout. Labels right-aligned Inter 500 13px gray-500, values left-aligned Inter 400 14px gray-900. (d) Active Problems card: traffic light dots + problem text + date in Geist Mono 12px. (e) Quick Meds card: 4-column table with top 5 skills. "View Full List ->" link. (f) Last Consultation card: preview with "View Full Record ->" link. All navigation links must actually switch the sidebar view.
|
||||||
|
|
||||||
Create `src/components/PatientBanner.tsx` with two modes. Full banner (80px): patient name "CHARLWOOD, Andrew (Mr)", DOB "14/02/1993", NHS No "221 181 0" (GPhC number formatted), address "Norwich, NR1", phone, email, status "Active" (green dot), badge "Open to opportunities". Action buttons: Download CV, Email, LinkedIn. Condensed banner (48px, sticky after 100px scroll): name, NHS No, status dot, action buttons only. Use `useScrollCondensation` hook with IntersectionObserver. Smooth height transition (200ms). Banner spans full viewport width.
|
- [ ] **Task 7: Rebuild ConsultationsView.** Read `Ralph/refs/ref-consultations.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/views/ConsultationsView.tsx`: (a) Collapsed entries: date in Geist Mono 13px gray-500, organization in Inter 400 13px NHS blue, role in Inter 600 15px gray-900, "Key:" prefix in Inter 500 gray-500 with single-line achievement. Chevron button right-aligned. (b) Status dot: green for current, gray for historical. (c) 3px left border color-coded by employer (NHS blue `#005EB8` or Tesco teal `#00897B`). (d) Expanded state: Duration line, then HISTORY / EXAMINATION / PLAN sections with headers in Inter 600, 12px, uppercase, letter-spacing 0.05em, gray-400. Plan items as bullet lists. (e) CODED ENTRIES section at bottom: Geist Mono 12px, gray-500, bracket codes. (f) Height-only expand animation, 200ms ease-out. NO opacity fade on content — content simply grows/shrinks. (g) Only one expanded at a time. (h) Implement the second clinical alert (Python switching algorithm alert) that appears on first navigation to Consultations view, dismissible with same Acknowledge pattern.
|
||||||
|
|
||||||
- [x] **Task 5: Build ClinicalSidebar component with navigation and search**
|
- [ ] **Task 8: Rebuild MedicationsView.** Read `Ralph/refs/ref-medications.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/views/MedicationsView.tsx`: (a) Three category tabs: "Active Medications" (technical), "Clinical Medications" (healthcare domain), "PRN (As Required)" (strategic/leadership). Tab styling: active tab has white bg + NHS blue bottom border. (b) Full table with proper `<table>` markup: Drug Name | Dose | Frequency | Start | Status. Table headers: Inter 600, 13px, uppercase, `#F9FAFB` bg. Row height: 40px. All borders: `1px solid #E5E7EB`. Alternating `#FFFFFF`/`#F9FAFB` row backgrounds. (c) Hover: `#EFF6FF` background only — no transform, no lift. (d) Sortable columns: click header to sort, arrow indicator in active column. (e) Status dots: 6px green circles + "Active" text. (f) Expandable prescribing history: Geist Mono 12px, year markers bold, descriptions regular. (g) Mobile: card layout (stacked fields per card, not table).
|
||||||
|
|
||||||
Create `src/components/ClinicalSidebar.tsx`. 220px width (desktop), dark blue-gray (#1E293B) background. Header: "CareerRecord PMR v1.0.0". 7 navigation items with Lucide icons: Summary (ClipboardList), Consultations (FileText), Medications (Pill), Problems (AlertTriangle), Investigations (FlaskConical), Documents (FolderOpen), Referrals (Send). Active state: 3px NHS blue left border, white background tint. Separator line after Summary. Footer: "Session: A.CHARLWOOD" and current time. Search input in header with fuse.js integration. Clicking item updates active view instantly (no animation). URL hash updates (#summary, #consultations, etc.).
|
- [ ] **Task 9: Rebuild ProblemsView.** Read `Ralph/refs/ref-problems.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/views/ProblemsView.tsx`: (a) Two sections: "ACTIVE PROBLEMS" and "RESOLVED PROBLEMS" with section headers in clinical style. (b) Table columns — Active: Status | Code | Problem | Since. Resolved: Status | Code | Problem | Resolved | Outcome. (c) Traffic light dots: 8px circles (green=resolved, amber=in-progress). Always paired with text labels — never the sole indicator. (d) Code column in Geist Mono. (e) Expandable rows showing full narrative + linked consultation buttons that navigate to Consultations view. (f) Row hover: `#EFF6FF`. (g) Mobile: card layout.
|
||||||
|
|
||||||
- [x] **Task 6: Build SummaryView component with clinical alert**
|
- [ ] **Task 10: Rebuild InvestigationsView and DocumentsView.** Read `Ralph/refs/ref-investigations-documents.md` and `Ralph/refs/ref-design-system.md`. Rebuild both views: (a) InvestigationsView: table with Test Name | Requested | Status | Result. Status badges: green "Complete", amber "Ongoing", pulsing green "Live". Expanded view uses tree-indented monospace structure with box-drawing characters (pipe symbols). "View Results" NHS blue button for PharMetrics only. (b) DocumentsView: table with Type icon | Document | Date | Source. Lucide icons per document type (FileText, Award, GraduationCap, FlaskConical). Expanded preview uses same tree-indented structure. (c) Both views share the expandable-row pattern — maintain visual consistency. (d) Mobile: card layouts for both.
|
||||||
|
|
||||||
Create `src/components/views/SummaryView.tsx`. Grid layout with cards: Patient Demographics (full width, two-column key-value table), Active Problems (left column, green/amber dots with dates), Current Medications Quick View (right column, 4-column table showing top 5 skills), Last Consultation preview (full width, truncated to 2-3 lines with "View Full Record" link). Clinical Alert banner: amber background (#FEF3C7), amber left border, warning icon, text "ALERT: This patient has identified £14.6M in prescribing efficiency savings...", Acknowledge button. Alert slides down with spring animation (250ms) after view loads. Clicking Acknowledge: icon changes to green checkmark (200ms), then alert collapses upward (200ms).
|
- [ ] **Task 11: Rebuild ReferralsView.** Read `Ralph/refs/ref-referrals.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/views/ReferralsView.tsx`: (a) Form header: "New Referral" with pre-filled patient info (non-editable). (b) Priority radio buttons: Urgent (red), Routine (blue, default), Two-Week Wait (amber). Each with tongue-in-cheek tooltips. (c) Form fields: standard clinical inputs with `1px solid #D1D5DB` border, 4px radius, `8px 12px` padding. Labels in Inter 500, 13px, gray-600 above inputs. Focus: NHS blue border + `box-shadow: 0 0 0 3px rgba(0,94,184,0.15)`. (d) Contact Method radio group. (e) Submit: "Send Referral" NHS blue button. Loading spinner state. Success state with reference number (REF-YYYY-MMDD-NNN format), checkmark, "24-48 hours" response time. (f) Direct Contact table below form: Email, Phone, LinkedIn, Location with action buttons.
|
||||||
|
|
||||||
- [x] **Task 7: Build ConsultationsView with History/Examination/Plan structure**
|
- [ ] **Task 12: Implement fuzzy search with fuse.js.** Read `Ralph/refs/ref-interactions.md`. Install fuse.js (`npm install fuse.js`). Rebuild the search functionality in the sidebar: (a) Build a search index on mount from ALL content: consultation titles/descriptions, medication names, problem descriptions, investigation names, document titles. (b) Search options: `{ keys: ['title', 'description', 'name'], threshold: 0.3, includeScore: true }`. (c) Results dropdown grouped by section (Consultations, Medications, Problems, etc.) with section icon, matching text, and relevance indicator. (d) Clicking a result navigates to the section AND expands/highlights the matching item. (e) Dropdown styled: white bg, `1px solid #E5E7EB`, 4px radius, shadow. Items in Inter 400 14px, 36px height. (f) Escape closes dropdown. (g) Mobile: search bar at top of each view instead of sidebar.
|
||||||
|
|
||||||
Create `src/components/views/ConsultationsView.tsx`. Reverse-chronological journal of 5 roles. Each entry: collapsed state shows date, organization (NHS blue), role title, key coded entry, expand chevron. Click to expand: shows Duration, HISTORY section (context/background), EXAMINATION section (bullet list of analysis/findings), PLAN section (bullet list of outcomes), CODED ENTRIES (SNOMED-style codes like [EFF001], [ALG001]). Section headers styled as clinical consultation dividers (uppercase, letter-spacing). Only one entry expanded at a time. Color-coded left border: NHS blue for NHS N&W ICB, Teal (#00897B) for Tesco PLC. Expand animation: height 0→auto (200ms, ease-out).
|
- [ ] **Task 13: Implement context menus.** Read `Ralph/refs/ref-interactions.md`. Add right-click/long-press context menus: (a) On consultation entries: "Expand", "Copy to clipboard", "View coded entries". (b) On medication rows: "View prescribing history", "Copy to clipboard". (c) On problem entries: "View linked consultations", "Copy to clipboard". (d) Styled: white bg, `1px solid #E5E7EB` border, 4px radius, `box-shadow: 0 4px 12px rgba(0,0,0,0.1)`. Items: Inter 400, 14px, 36px row height. (e) Escape closes menu. Focus returns to trigger element on close. (f) Use Headless UI or a custom implementation — accessible with keyboard navigation.
|
||||||
|
|
||||||
- [x] **Task 8: Build MedicationsView with sortable table and prescribing history**
|
- [ ] **Task 14: Responsive design audit and fix.** Read `Ralph/refs/ref-interactions.md`. Systematically test and fix all three breakpoints: (a) Desktop (>1024px): full sidebar 220px, full patient banner 80px condensing to 48px, full tables. (b) Tablet (768-1024px): sidebar collapses to 56px icon-only with tooltips on hover/tap. Patient banner always condensed. Tables may horizontally scroll. Context menus via long-press. (c) Mobile (<768px): bottom nav bar with 7 icon buttons (56px height + safe area). Minimal top bar. ALL tables convert to card layout (stacked fields). Search bar at top of views. Back arrow to Summary. Touch targets minimum 48px. (d) Run `npm run build` and verify no layout breaks.
|
||||||
|
|
||||||
Create `src/components/views/MedicationsView.tsx`. Three category tabs: Active Medications (technical skills), Clinical Medications (healthcare domain skills), PRN (strategic skills). Each tab shows a table: Drug Name | Dose (%) | Frequency | Start | Status. Sortable columns: clicking header sorts (asc/desc toggle). Default sort: by category grouping. Table styling: gray-200 borders, alternating row colors, 40px row height. Hover: subtle blue tint (#EFF6FF). Click row to expand "Prescribing History" — mini-timeline showing skill progression (year + description). History styled in Geist Mono. 18 total medications mapped from CV skills with accurate proficiency percentages and usage frequencies.
|
- [ ] **Task 15: Accessibility audit and final polish.** Read `Ralph/refs/ref-interactions.md`. Audit and fix accessibility: (a) Semantic HTML: sidebar `<nav>` with `aria-label`, main `<main>`, banner `<header>`, tables with `scope="col"`. (b) ARIA: `aria-current="page"` on active sidebar item, `aria-expanded` on expandable items, `role="alert"` on clinical alerts. (c) Focus management: after login focus to sidebar, after view switch focus to first heading, after expand focus to section heading, after alert dismiss focus to main content. (d) Keyboard: Tab order, roving tabindex in sidebar, Escape closes everything. (e) Screen reader: announce view changes, table headers, alert text. (f) `prefers-reduced-motion`: login instant, alert no slide, expand/collapse instant, banner condensation instant. (g) Color contrast: verify all text meets WCAG 2.1 AA. Traffic lights always have text labels. (h) Final visual polish pass: ensure consistent spacing, borders, fonts across all views.
|
||||||
|
|
||||||
- [x] **Task 9: Build ProblemsView with traffic light system**
|
|
||||||
|
|
||||||
Create `src/components/views/ProblemsView.tsx`. Two sections: Active Problems and Resolved Problems. Table columns: Status (traffic light dot), Code (SNOMED-style in Geist Mono), Problem description, Since/Resolved date, Outcome (for resolved). Traffic lights: 8px circles — green (resolved/current), amber (in progress), gray (inactive/historical). Active problems: £220M budget oversight, SQL transformation, data literacy programme. Resolved problems: 8 achievements with specific outcomes ("Python algorithm: 14,000 pts, £2.6M/yr", "70% reduction, 200hrs saved", etc.). Click row to expand full narrative with "linked consultations" navigation.
|
|
||||||
|
|
||||||
- [x] **Task 10: Build InvestigationsView with results panel**
|
|
||||||
|
|
||||||
Create `src/components/views/InvestigationsView.tsx`. Projects presented as diagnostic investigations. Table: Test Name | Requested | Status | Result. Status badges: Complete (green dot), Ongoing (amber dot), Live (pulsing green dot for PharMetrics). 5 investigations: PharMetrics Interactive Platform, Patient Switching Algorithm, Blueteq Generator, CD Monitoring System, Sankey Chart Analysis Tool. Click row to expand "results panel" with tree-indented structure: Date Requested, Date Reported, Status, Requesting Clinician, Methodology, Results, Tech Stack. PharMetrics has "View Results" button linking to medicines.charlwood.xyz.
|
|
||||||
|
|
||||||
- [x] **Task 11: Build DocumentsView for education/certifications**
|
|
||||||
|
|
||||||
Create `src/components/views/DocumentsView.tsx`. Education presented as attached documents. Table: Type (icon), Document, Date, Source. Icons: FileText (certificates), Award (registrations), GraduationCap (academic), FlaskConical (research). 4 documents: MPharm (Hons) 2:1 UEA 2015, GPhC Registration 2016, Mary Seacole Programme 2018, A-Levels 2011 + Drug Delivery Research. Click to expand "preview" panel with tree-indented details: Type, Date Awarded, Institution, Classification, Duration, Research details, Notes. Consistent with Investigations expanded view style.
|
|
||||||
|
|
||||||
- [x] **Task 12: Build ReferralsView with clinical referral form**
|
|
||||||
|
|
||||||
Create `src/components/views/ReferralsView.tsx`. Contact presented as clinical referral form. Form fields: Referring to (pre-filled: CHARLWOOD, Andrew), NHS Number (pre-filled), Priority toggle (radio: Urgent [red], Routine [blue/selected], Two-Week Wait [amber] with tongue-in-cheek tooltips), Referrer Name/Email/Org inputs, Reason for Referral textarea, Contact Method radio (Email/Phone/LinkedIn). Submit button: NHS blue, full width right half. On submit: loading spinner, then success message with reference number (REF-2026-0210-001 format). Below form: Direct Contact table with Email, Phone, LinkedIn, Location as clickable links.
|
|
||||||
|
|
||||||
- [ ] **Task 13: Implement keyboard shortcuts and accessibility**
|
|
||||||
|
|
||||||
Add keyboard navigation throughout. Global shortcuts: Alt+1-7 activate sidebar items, Escape closes expanded items/menus, / focuses search. Sidebar: Up/Down arrows navigate items, Enter activates. Implement focus management: after login, focus moves to first sidebar item; after view change, focus moves to view heading; after expanding item, focus moves to content. Add ARIA: `role="navigation"` on sidebar, `aria-current="page"` on active item, `role="alert"` on clinical alert, proper table markup with `scope="col"`, `aria-expanded` on expandable items. Test with screen reader: views announced, tables navigable, alert read immediately.
|
|
||||||
|
|
||||||
- [ ] **Task 14: Implement responsive design (tablet and mobile)**
|
|
||||||
|
|
||||||
Tablet (768-1024px): Sidebar collapses to 56px icon-only with tooltips on hover. Patient banner always condensed (48px). Tables may horizontally scroll with indicator. Mobile (<768px): Sidebar becomes bottom navigation bar (56px height, 7 icon buttons, safe area padding). Patient banner becomes minimal top bar. Tables switch to card layout (each row becomes stacked card). Search moves to top of each view. Add back navigation arrow in each view. Test all breakpoints: desktop (>1024), tablet (768-1024), mobile (<768). Ensure touch targets minimum 48px. Test on actual mobile device or emulator.
|
|
||||||
|
|
||||||
- [ ] **Task 15: Final integration, testing, and polish**
|
|
||||||
|
|
||||||
Wire up App.tsx with three phases: BootSequence (4s) → ECGAnimation (modified for flatline) → LoginScreen (1.2s) → PMRInterface. Ensure smooth transitions between phases. Run all quality checks. Verify TypeScript strict mode (no `any` types). Verify all CV content accuracy against CV_v4.md (dates, numbers, achievements). Test all interactive elements: sidebar nav, consultation expand, medication sort, alert acknowledge, referral form submit. Verify responsive layouts at all breakpoints. Test accessibility with keyboard navigation and screen reader. Verify search finds content across all sections. Final production build test.
|
|
||||||
|
|||||||
+78
-16
@@ -2,14 +2,24 @@
|
|||||||
|
|
||||||
You are operating inside an automated loop. Each iteration you receive fresh context - you have NO memory of previous iterations. Your only persistence is the filesystem.
|
You are operating inside an automated loop. Each iteration you receive fresh context - you have NO memory of previous iterations. Your only persistence is the filesystem.
|
||||||
|
|
||||||
You are implementing **Design 7: The Clinical Record** — a Patient Medical Record (PMR) system that presents Andy's CV as a clinician would view a patient record. This is a complete redesign from the previous ECG Heartbeat concept.
|
You are implementing **Design 7: The Clinical Record** — a Patient Medical Record (PMR) system that presents Andy's CV as a clinician would view a patient record. This is a visual redesign rebuilding existing components to achieve absolute thematic fidelity to real NHS clinical software.
|
||||||
|
|
||||||
**The Concept:**
|
**The Concept:**
|
||||||
The "patient" is Andy's career. Users navigate a genuine NHS clinical software interface (similar to EMIS Web, SystmOne, Vision) with a patient banner, sidebar navigation, consultation journal, medications table, clinical alerts, and a login sequence. The design works on two levels: clinicians recognize the interface immediately; recruiters get a novel, information-dense presentation.
|
The "patient" is Andy's career. Users navigate a genuine NHS clinical software interface (similar to EMIS Web, SystmOne, Vision) with a patient banner, sidebar navigation, consultation journal, medications table, clinical alerts, and a login sequence. The clinical metaphor lives in the LAYOUT and VISUAL PRESENTATION — the sidebar labels use CV-friendly terms (Experience, Skills, Achievements, Projects, Education, Contact) while each view is laid out like its clinical equivalent (consultation journal, medications table, problems list, etc.).
|
||||||
|
|
||||||
|
**IMPORTANT — Sidebar Label Convention:**
|
||||||
|
The sidebar uses CV-intuitive labels, NOT clinical jargon. But each view's content is presented in the clinical format:
|
||||||
|
- **Summary** → Patient summary layout
|
||||||
|
- **Experience** (not "Consultations") → Consultation journal layout with History/Examination/Plan
|
||||||
|
- **Skills** (not "Medications") → Medications table layout with dosages/frequency
|
||||||
|
- **Achievements** (not "Problems") → Problems list layout with traffic lights
|
||||||
|
- **Projects** (not "Investigations") → Investigation results layout
|
||||||
|
- **Education** (not "Documents") → Attached documents layout
|
||||||
|
- **Contact** (not "Referrals") → Referral form layout
|
||||||
|
|
||||||
## Your Task This Iteration
|
## Your Task This Iteration
|
||||||
|
|
||||||
1. **Use the /frontend-design skill** (REQUIRED for visual components): Before writing ANY code for components that involve visual design, styling, animations, or UI elements, you MUST invoke the `/frontend-design` skill. This includes: LoginScreen, PatientBanner, ClinicalSidebar, ClinicalAlert, all View components (Summary, Consultations, Medications, Problems, Investigations, Documents, Referrals), and any table, card, or form component.
|
1. **Read the Design Guidance in the reference file** (REQUIRED for visual components): Each reference file in `Ralph/refs/` contains a "Design Guidance (from /frontend-design)" section at the bottom with pre-generated design direction, code patterns, and implementation details. You MUST read this section before writing code — it provides the aesthetic direction and code examples for the component. Do NOT invoke the `/frontend-design` skill at runtime — the guidance is already embedded in the ref files.
|
||||||
|
|
||||||
2. **Read the plan**: Open `IMPLEMENTATION_PLAN.md` and find the highest-priority unchecked item (`- [ ]`). Items are listed in priority order - pick the first unchecked one.
|
2. **Read the plan**: Open `IMPLEMENTATION_PLAN.md` and find the highest-priority unchecked item (`- [ ]`). Items are listed in priority order - pick the first unchecked one.
|
||||||
|
|
||||||
@@ -25,24 +35,61 @@ The "patient" is Andy's career. Users navigate a genuine NHS clinical software i
|
|||||||
- Login typing animation specifics
|
- Login typing animation specifics
|
||||||
- Consultation History/Examination/Plan format
|
- Consultation History/Examination/Plan format
|
||||||
- Coded entries in [XXX000] format
|
- Coded entries in [XXX000] format
|
||||||
|
- Sidebar labels are CV-friendly (Experience, Skills, etc.), NOT clinical jargon
|
||||||
|
|
||||||
5. **Implement the item**: Complete the single task you selected. Keep changes focused - one task per iteration. Write production-quality React/TypeScript code that faithfully reproduces a clinical information system. This is a design showcase requiring absolute thematic fidelity.
|
5. **Implement the item**: Complete the single task you selected. Keep changes focused - one task per iteration. Write production-quality React/TypeScript code that faithfully reproduces a clinical information system. This is a design showcase requiring absolute thematic fidelity.
|
||||||
|
|
||||||
6. **Run quality checks**: Execute the quality check commands listed in `IMPLEMENTATION_PLAN.md` under "Quality Checks". Fix any issues before proceeding.
|
6. **Run quality checks**: Execute the quality check commands listed in `IMPLEMENTATION_PLAN.md` under "Quality Checks". Fix any issues before proceeding.
|
||||||
|
|
||||||
7. **Commit your changes**: Stage and commit all changes with a descriptive message referencing the task you completed.
|
7. **Visual Review** (Tasks 1b-11 only — skip for non-visual tasks like Task 1, 12-15): After quality checks pass, verify your work visually in the browser using the Claude in Chrome browser tools:
|
||||||
|
a. Call `tabs_context_mcp` to get available tabs (create if empty).
|
||||||
|
b. Navigate to `http://localhost:5173` (dev server runs throughout the loop).
|
||||||
|
c. **First load only**: The app plays a boot→ECG→login→PMR sequence (~15s). Use `computer` with `action: "wait", duration: 15` then take a screenshot. On subsequent navigations in the same tab, the app stays in PMR phase — no waiting needed.
|
||||||
|
d. Navigate to the hash route for your task's view:
|
||||||
|
- Task 1b (Boot/ECG): Refresh page, screenshot during boot sequence, then again during ECG animation
|
||||||
|
- Task 2 (Login): Refresh page, wait ~8s (after boot+ECG), screenshot the login screen
|
||||||
|
- Task 3 (Banner): Any PMR view — review the patient banner at top
|
||||||
|
- Task 4 (Sidebar): Any PMR view — review left sidebar
|
||||||
|
- Task 5 (Layout/Breadcrumb): Any PMR view — review overall composition
|
||||||
|
- Task 6: `#summary` | Task 7: `#experience` | Task 8: `#skills`
|
||||||
|
- Task 9: `#achievements` | Task 10: `#projects` then `#education` | Task 11: `#contact`
|
||||||
|
e. Take a screenshot (`computer` with `action: "screenshot"`) and compare against your reference file.
|
||||||
|
f. Check specifically: colors match spec, correct font (Inter vs Geist Mono), proper spacing, `1px solid #E5E7EB` borders, 4px border-radius, layout alignment, NHS blue `#005EB8`.
|
||||||
|
g. If discrepancies are found: fix them, re-run quality checks, take another screenshot to confirm.
|
||||||
|
h. Note the visual review outcome in your progress.txt entry (step 10).
|
||||||
|
|
||||||
8. **Mark the item complete**: In `IMPLEMENTATION_PLAN.md`, change the item from `- [ ]` to `- [x]`.
|
8. **Commit your changes**: Stage and commit all changes with a descriptive message referencing the task you completed.
|
||||||
|
|
||||||
9. **Update progress.txt**: Append to the "Iteration Log" section with:
|
9. **Mark the item complete**: In `IMPLEMENTATION_PLAN.md`, change the item from `- [ ]` to `- [x]`.
|
||||||
|
|
||||||
|
10. **Update progress.txt**: Append to the "Iteration Log" section with:
|
||||||
- Which task you completed
|
- Which task you completed
|
||||||
- Any learnings or codebase patterns discovered (add to "Codebase Patterns" section)
|
- Any learnings or codebase patterns discovered (add to "Codebase Patterns" section)
|
||||||
- Any issues encountered
|
- Any issues encountered
|
||||||
- Design decisions made (if visual component)
|
- Design decisions made (if visual component)
|
||||||
|
- Visual review outcome (what was checked, any fixes made)
|
||||||
|
|
||||||
10. **Commit the progress update**: Stage and commit the updated `IMPLEMENTATION_PLAN.md` and `progress.txt`.
|
11. **Commit the progress update**: Stage and commit the updated `IMPLEMENTATION_PLAN.md` and `progress.txt`.
|
||||||
|
|
||||||
11. **Determine if another iteration is needed**: Review your work and the codebase. The project needs another iteration if ANY of these are true:
|
12. **Recommend model for next iteration**: Look at the NEXT unchecked task in `IMPLEMENTATION_PLAN.md` (the one after the task you just completed). Assess its complexity and output a model recommendation on its own line:
|
||||||
|
|
||||||
|
```
|
||||||
|
<next-model>sonnet</next-model>
|
||||||
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
```
|
||||||
|
<next-model>opus</next-model>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use this decision framework:**
|
||||||
|
- **Use `sonnet`** for: configuration tasks, search/utility implementation, responsive fixes, accessibility audits, tasks with very prescriptive specs, tasks that are mostly wiring/plumbing
|
||||||
|
- **Use `opus`** for: visual component rebuilds that invoke /frontend-design (design quality matters), complex animation work, tasks requiring strong aesthetic judgment, tasks where the previous iteration left issues that need creative problem-solving
|
||||||
|
- **Default to `sonnet`** if unsure — it's cheaper and handles well-specified tasks fine
|
||||||
|
- If there IS no next task (you just completed the last one), skip this step
|
||||||
|
|
||||||
|
13. **Determine if another iteration is needed**: Review your work and the codebase. The project needs another iteration if ANY of these are true:
|
||||||
- Any task in the checklist is unchecked (`- [ ]`) or blocked (`- [B]`)
|
- Any task in the checklist is unchecked (`- [ ]`) or blocked (`- [B]`)
|
||||||
- Quality checks would fail (run them to verify)
|
- Quality checks would fail (run them to verify)
|
||||||
- There are uncommitted changes
|
- There are uncommitted changes
|
||||||
@@ -50,17 +97,19 @@ The "patient" is Andy's career. Users navigate a genuine NHS clinical software i
|
|||||||
- The implementation doesn't fully satisfy the plan requirements
|
- The implementation doesn't fully satisfy the plan requirements
|
||||||
- You have lingering doubts about correctness or completeness
|
- You have lingering doubts about correctness or completeness
|
||||||
|
|
||||||
12. **Send completion signal ONLY if truly complete**: If and ONLY if the project definitely does NOT need another iteration — all tasks verified done, quality checks pass, no guidance for next iteration — output this exact signal on its own line:
|
14. **Send completion signal ONLY if truly complete**: If and ONLY if the project definitely does NOT need another iteration — all tasks verified done, quality checks pass, no guidance for next iteration — output this exact signal on its own line:
|
||||||
|
|
||||||
```
|
```
|
||||||
<promise>COMPLETE</promise>
|
<promise>COMPLETE</promise>
|
||||||
```
|
```
|
||||||
|
|
||||||
DO NOT output this string if there's any chance another iteration is needed. When in doubt, do NOT send the promise — leave it for the next iteration to determine.
|
DO NOT output this string if there's any chance another iteration is needed. When in doubt, do NOT send the promise — leave it for the next iteration to determine.
|
||||||
|
|
||||||
## Critical Rules
|
## Critical Rules
|
||||||
|
|
||||||
- **ALWAYS invoke /frontend-design skill before writing visual component code** — this is mandatory for all UI components
|
- **ALWAYS read the "Design Guidance" section in the ref file before writing visual component code** — do NOT invoke /frontend-design at runtime (it's pre-baked into the ref files)
|
||||||
|
- **Do NOT invoke the /frontend-design skill** — the design guidance is already embedded in each ref file. Invoking it at runtime will consume your context and stall the iteration.
|
||||||
|
- **ALWAYS visually review visual components (Tasks 1b-11) in the browser** — use Claude in Chrome tools to screenshot and verify against the spec before committing
|
||||||
- **Only work on ONE task per iteration**
|
- **Only work on ONE task per iteration**
|
||||||
- **Always read progress.txt AND guardrails.md before starting** — previous iterations may have left important context
|
- **Always read progress.txt AND guardrails.md before starting** — previous iterations may have left important context
|
||||||
- **If a task is blocked or unclear**, document why in progress.txt and move to the next unchecked item
|
- **If a task is blocked or unclear**, document why in progress.txt and move to the next unchecked item
|
||||||
@@ -68,15 +117,28 @@ DO NOT output this string if there's any chance another iteration is needed. Whe
|
|||||||
- **If quality checks fail, fix the issues before committing**
|
- **If quality checks fail, fix the issues before committing**
|
||||||
- **The visual quality bar is HIGH** — this must look like real clinical software
|
- **The visual quality bar is HIGH** — this must look like real clinical software
|
||||||
- **Preserve clinical system authenticity** — instant navigation, proper tables, NHS blue, coded entries, traffic lights
|
- **Preserve clinical system authenticity** — instant navigation, proper tables, NHS blue, coded entries, traffic lights
|
||||||
|
- **Sidebar labels are CV-friendly** — Experience (not Consultations), Skills (not Medications), etc.
|
||||||
- **Use TypeScript strictly** — no `any` types, proper interfaces for all PMR data structures
|
- **Use TypeScript strictly** — no `any` types, proper interfaces for all PMR data structures
|
||||||
- **Follow the established project structure** — components in `src/components/`, data in `src/data/`, types in `src/types/`
|
- **Follow the established project structure** — components in `src/components/`, data in `src/data/`, types in `src/types/`
|
||||||
- **Respect prefers-reduced-motion** — animations must have instant fallbacks
|
- **Respect prefers-reduced-motion** — animations must have instant fallbacks
|
||||||
|
|
||||||
## Reference Files
|
## Reference Files
|
||||||
|
|
||||||
- `designs/07-the-clinical-record.md` — Complete design specification with all visual details, animations, and interactions
|
Each task in the implementation plan references specific files in `Ralph/refs/`:
|
||||||
|
- `Ralph/refs/ref-boot-ecg.md` — Boot sequence + ECG animation improvements
|
||||||
|
- `Ralph/refs/ref-design-system.md` — Colors, typography, spacing, borders, motion
|
||||||
|
- `Ralph/refs/ref-transition-login.md` — ECG flatline + login sequence
|
||||||
|
- `Ralph/refs/ref-banner-sidebar.md` — Patient banner + sidebar + navigation
|
||||||
|
- `Ralph/refs/ref-summary-alert.md` — Summary view + clinical alert
|
||||||
|
- `Ralph/refs/ref-consultations.md` — Experience view (consultation journal layout)
|
||||||
|
- `Ralph/refs/ref-medications.md` — Skills view (medications table layout)
|
||||||
|
- `Ralph/refs/ref-problems.md` — Achievements view (problems list layout)
|
||||||
|
- `Ralph/refs/ref-investigations-documents.md` — Projects + Education views
|
||||||
|
- `Ralph/refs/ref-referrals.md` — Contact view (referral form layout)
|
||||||
|
- `Ralph/refs/ref-interactions.md` — Interactions, responsive, accessibility
|
||||||
- `References/CV_v4.md` — Source CV content (roles, achievements, numbers, dates)
|
- `References/CV_v4.md` — Source CV content (roles, achievements, numbers, dates)
|
||||||
- `References/concept.html` — Previous ECG implementation (timing reference only for boot sequence)
|
|
||||||
|
Read ONLY the referenced file(s) for each task. Do NOT read goal.md directly.
|
||||||
|
|
||||||
## Design Document Highlights
|
## Design Document Highlights
|
||||||
|
|
||||||
|
|||||||
+76
-95
@@ -1,120 +1,101 @@
|
|||||||
# Guardrails — Clinical Record PMR System
|
# Guardrails
|
||||||
|
|
||||||
## Standard Guardrails
|
Hard rules that MUST be followed in every iteration. Violating these will produce incorrect output.
|
||||||
|
|
||||||
### Frontend-design skill requirement
|
## Design System Guardrails
|
||||||
- **When**: Writing ANY component with visual styling, animations, or UI elements
|
|
||||||
- **Rule**: You MUST invoke the `/frontend-design` skill before writing code. This applies to: LoginScreen, PatientBanner, ClinicalSidebar, ClinicalAlert, all View components (Summary, Consultations, Medications, Problems, Investigations, Documents, Referrals), and any table, card, or form component.
|
|
||||||
- **Why**: The frontend-design skill provides specialized capabilities for creating polished, professional-grade visual output. This is a high-fidelity clinical interface requiring exact color matching and spacing.
|
|
||||||
|
|
||||||
### Light-mode only constraint
|
### When: Writing ANY visual component
|
||||||
- **When**: Implementing any styling for the PMR interface
|
**Rule:** Light-mode only. Do NOT add dark mode classes, `dark:` prefixes, or theme toggles. Clinical record systems operate exclusively in light mode.
|
||||||
- **Rule**: This design is LIGHT-MODE ONLY. Never implement dark mode. Clinical systems operate in light mode due to high ambient lighting in consulting rooms. Use white backgrounds (`#FFFFFF`), cool light gray content areas (`#F5F7FA`), and dark text (`#111827`).
|
**Why:** Dark mode breaks the clinical system metaphor. NHS clinical software is always light-mode due to high ambient lighting in consulting rooms.
|
||||||
- **Why**: Dark mode would break the clinical system metaphor entirely.
|
|
||||||
|
|
||||||
### Clinical system navigation behavior
|
### When: Setting border-radius on cards, inputs, or table elements
|
||||||
- **When**: Implementing sidebar navigation and view switching
|
**Rule:** Use 4px border-radius (`rounded` in Tailwind, which is 4px). Do NOT use `rounded-lg` (8px), `rounded-xl` (12px), or `rounded-2xl` (16px). The only exception is the LoginScreen card which uses 12px.
|
||||||
- **Rule**: View switching must be INSTANT — no crossfade, no slide animation, no transition. When a sidebar item is clicked, the main content area replaces immediately. This matches EMIS Web, SystmOne, and other clinical systems exactly.
|
**Why:** Clinical systems use minimal rounding. Larger radii look like consumer apps, not NHS software.
|
||||||
- **Why**: Clinical systems prioritize speed and responsiveness over visual flair. Any animation here breaks the authenticity.
|
|
||||||
|
|
||||||
### Table markup requirements
|
### When: Using monospace/code font
|
||||||
- **When**: Building the Medications, Problems, Investigations, or Documents tables
|
**Rule:** Use Geist Mono (font-family: 'Geist Mono', monospace), NOT Fira Code, for coded entries, timestamps, clinical codes, and data values.
|
||||||
- **Rule**: Use proper semantic HTML `<table>`, `<thead>`, `<tbody>`, `<tr>`, `<th>`, `<td>` elements. Headers must use `scope="col"`. Never use divs styled as tables. Tables must have `1px solid #E5E7EB` borders on all cells, 40px row height, and alternating row colors.
|
**Why:** The spec requires Geist Mono. Fira Code was used in the ECG/boot phase but is wrong for the PMR interface.
|
||||||
- **Why**: Screen readers rely on proper table markup to navigate data tables. Clinical systems use real tables.
|
|
||||||
|
|
||||||
### Traffic light accessibility
|
### When: Adding shadows to cards or panels
|
||||||
- **When**: Using green/amber/red status indicators
|
**Rule:** No shadows, or at most `0 1px 2px rgba(0,0,0,0.03)`. Do NOT use prominent shadows like `shadow-md` or `shadow-lg`.
|
||||||
- **Rule**: Traffic light dots (8px circles) must ALWAYS accompany text labels ("Active", "Resolved", "In Progress"). Never use color alone to communicate status. This applies to Problems status, Medications status, and Investigations status.
|
**Why:** Clinical systems structure with borders, not shadows. Prominent shadows look like marketing sites.
|
||||||
- **Why**: WCAG 2.1 AA requirement — color cannot be the sole means of conveying information.
|
|
||||||
|
|
||||||
### CV content accuracy
|
### When: Styling borders
|
||||||
- **When**: Adding CV content to data files
|
**Rule:** All card and table borders must be `1px solid #E5E7EB` (gray-200). Use `border-gray-200` in Tailwind.
|
||||||
- **Rule**: Use exact data from `References/CV_v4.md`. Key numbers must match: £14.6M efficiency programme, 14,000 patients, £2.6M savings, 70% reduction, 200 hours saved, £1M revenue, £220M budget. Dates must be accurate: Interim Head (May-Nov 2025), Deputy Head (Jul 2024-Present), etc.
|
**Why:** This is the universal border color in NHS clinical software.
|
||||||
- **Why**: Inaccurate CV data is a critical error. The PMR system presents factual career information.
|
|
||||||
|
|
||||||
### NHS blue brand color
|
## Sidebar Label Convention
|
||||||
- **When**: Using the primary accent color
|
|
||||||
- **Rule**: Use exact NHS blue `#005EB8` for: active sidebar border, buttons, links, column headers, organization names. This is the actual NHS brand blue. Never use a different shade.
|
|
||||||
- **Why**: NHS blue is instantly recognizable to healthcare professionals. Wrong blue breaks the authenticity.
|
|
||||||
|
|
||||||
### TypeScript strictness
|
### When: Building or modifying sidebar navigation labels
|
||||||
- **When**: Writing any TypeScript code
|
**Rule:** Sidebar labels MUST use CV-friendly terms: Summary, Experience, Skills, Achievements, Projects, Education, Contact. Do NOT use clinical jargon (Consultations, Medications, Problems, Investigations, Documents, Referrals) as sidebar labels. The clinical metaphor lives in the LAYOUT of each view, not the navigation labels.
|
||||||
- **Rule**: No `any` types. Define interfaces for all data structures in `src/types/pmr.ts`. Use proper React.FC types or function component signatures with typed props. Enable strict mode in tsconfig.json.
|
**Why:** Non-clinical visitors should immediately understand what each section contains. The clinical system aesthetic comes from the visual presentation (consultation journal format, medications table format, etc.), not from the nav labels.
|
||||||
- **Why**: Type safety is critical for maintainability. The data layer has complex types (Consultation, Medication with history, Problem with codes).
|
|
||||||
|
|
||||||
### Reduced motion support
|
## Navigation Guardrails
|
||||||
- **When**: Implementing animations (login typing, alert slide, consultation expand)
|
|
||||||
- **Rule**: All animations must respect `prefers-reduced-motion: reduce`. With reduced motion: login typing completes instantly, alert appears without slide, consultation expand is instant, banner condensation is instant.
|
|
||||||
- **Why**: Accessibility requirement for users with vestibular disorders.
|
|
||||||
|
|
||||||
### No console errors
|
### When: Switching between sidebar views
|
||||||
- **When**: Writing JavaScript/TypeScript
|
**Rule:** View switching must be INSTANT. No crossfade, no slide animation, no opacity transition between views. The main content area simply replaces its content immediately.
|
||||||
- **Rule**: No errors in the browser console. Handle edge cases: fuse.js search with no results, table sorting with empty data, form validation, animation cleanup on unmount.
|
**Why:** Clinical systems use instant tab switching. Any animation makes it feel like a website, not clinical software.
|
||||||
- **Why**: Console errors suggest broken functionality and are a quality check failure.
|
|
||||||
|
|
||||||
### Responsive breakpoints
|
### When: Building navigation
|
||||||
- **When**: Adding responsive CSS/Tailwind classes
|
**Rule:** URL hash routing is required. Each view must update `window.location.hash` and the app must read the hash on load to navigate to the correct view.
|
||||||
- **Rule**: Must work at 3 breakpoints: desktop (>1024px with full sidebar), tablet (768-1024px with icon-only sidebar), mobile (<768px with bottom nav). Tables must adapt: full columns on desktop, scrollable on tablet, card layout on mobile.
|
**Why:** Direct linking to specific views is required for shareability.
|
||||||
- **Why**: Clinical records may be viewed on tablets in consulting rooms or mobile devices.
|
|
||||||
|
|
||||||
## Project-Specific Guardrails
|
## Component Guardrails
|
||||||
|
|
||||||
### ECG flatline transition
|
### When: Expanding/collapsing consultation entries
|
||||||
- **When**: Modifying ECGAnimation component
|
**Rule:** Use height animation ONLY (200ms, ease-out). Do NOT fade opacity on the content. Content simply grows/shrinks in height.
|
||||||
- **Rule**: The ECG must end with a flatline (horizontal line extending rightward from the name) that visually reads as a patient monitor flatline. This transitions to the login screen background (#1E293B). Do NOT fade to white — the previous design did that, but this design requires the flatline → login sequence.
|
**Why:** The spec explicitly states "No opacity fade — the content simply grows/shrinks."
|
||||||
- **Why**: The flatline signals "end of patient monitoring, opening clinical record." It's a narrative transition.
|
|
||||||
|
|
||||||
### Login typing animation
|
### When: Displaying traffic light status indicators
|
||||||
- **When**: Implementing LoginScreen component
|
**Rule:** Traffic lights (colored dots) must ALWAYS be accompanied by text labels (Active, Resolved, In Progress, etc.). Dots are never the sole indicator of state.
|
||||||
- **Rule**: Username "A.CHARLWOOD" types character-by-character at 30ms per character. Password fills with 8 dots at 20ms per dot. Use Geist Mono font for the typing. Blinking cursor appears during typing.
|
**Why:** WCAG accessibility — color cannot be the only means of communicating information.
|
||||||
- **Why**: The login sequence is the most immersive transition. Every NHS worker recognizes typing credentials into a clinical system.
|
|
||||||
|
|
||||||
### Consultation format fidelity
|
### When: Writing consultation entries
|
||||||
- **When**: Building ConsultationsView
|
**Rule:** Use History / Examination / Plan section headers (uppercase, Inter 600, 12px, letter-spacing 0.05em, gray-400). Include CODED ENTRIES at the bottom of each expanded consultation in [XXX000] format.
|
||||||
- **Rule**: Each consultation MUST have History, Examination, and Plan sections. Use uppercase section headers with letter-spacing (Inter 600, 12px, gray-400). History = context/background, Examination = analysis/findings (bullet list), Plan = outcomes/delivery (bullet list). Include coded entries at bottom in [XXX000] format.
|
**Why:** This is the core metaphor — SOAP notes format mapped to career content.
|
||||||
- **Why**: This is the clinical SOAP note format. The mapping to career content is the core concept.
|
|
||||||
|
|
||||||
### Medication table columns
|
### When: Rendering the clinical alert
|
||||||
- **When**: Building MedicationsView
|
**Rule:** Use Framer Motion `type: "spring"` animation for the alert entrance (not ease-out). The alert uses amber colors: bg `#FEF3C7`, left border `#F59E0B`, text `#92400E`.
|
||||||
- **Rule**: Table must have exactly these columns: Drug Name, Dose (%), Frequency, Start (year), Status. All columns must be sortable. Default grouping: Active Medications (technical), Clinical Medications (healthcare), PRN (strategic).
|
**Why:** The spec specifies spring animation with slight overshoot. Alerts demand attention.
|
||||||
- **Why**: Medications tables in clinical systems have standard columns. This mapping provides more information than typical skills sections.
|
|
||||||
|
|
||||||
### Clinical alert behavior
|
### When: Writing table markup
|
||||||
- **When**: Implementing ClinicalAlert component
|
**Rule:** Use semantic `<table>`, `<thead>`, `<th>`, `<tbody>`, `<tr>`, `<td>` elements. Column headers must include `scope="col"`. Do NOT use div-based table layouts.
|
||||||
- **Rule**: Alert appears on Summary view load with spring animation (250ms). Must include warning icon, amber background (#FEF3C7), amber left border, and "Acknowledge" button. Clicking Acknowledge: icon → green checkmark (200ms) → alert collapses upward (200ms). Use `role="alert"` and `aria-live="assertive"`.
|
**Why:** Screen readers navigate tables using native table semantics. Div tables are inaccessible.
|
||||||
- **Why**: The clinical alert is the signature interaction. It frames the £14.6M achievement with institutional weight.
|
|
||||||
|
|
||||||
### Coded entries format
|
## Data Guardrails
|
||||||
- **When**: Adding coded entries to consultations or problems
|
|
||||||
- **Rule**: Use fictional but consistent SNOMED-style codes: [EFF001] for efficiency, [ALG001] for algorithms, [AUT001] for automation, [SQL001] for data, [BUD001] for budget, [TRN001] for transformation, [LEA001] for leadership, etc. Codes in Geist Mono 12px, gray-500.
|
|
||||||
- **Why**: Clinical systems use coded entries (SNOMED CT, Read codes). This maintains the metaphor.
|
|
||||||
|
|
||||||
### Sidebar navigation structure
|
### When: Displaying CV content (dates, numbers, roles, achievements)
|
||||||
- **When**: Building ClinicalSidebar
|
**Rule:** All data must come from `src/data/*.ts` files. Do NOT hardcode CV content directly in components. Do NOT change any numbers or dates — they are sourced from the verified CV.
|
||||||
- **Rule**: Exactly 7 items in this order: Summary, Consultations, Medications, Problems, Investigations, Documents, Referrals. Use Lucide icons: ClipboardList, FileText, Pill, AlertTriangle, FlaskConical, FolderOpen, Send. Separator line after Summary. Active state: 3px NHS blue left border.
|
**Why:** Data accuracy is critical. The data layer has been validated against CV_v4.md.
|
||||||
- **Why**: This matches clinical record navigation categories. Order matters for Alt+1-7 shortcuts.
|
|
||||||
|
|
||||||
### Patient banner data
|
### When: Modifying data files
|
||||||
- **When**: Building PatientBanner
|
**Rule:** Do NOT modify data files in `src/data/` unless the task explicitly requires it. The data is correct and complete.
|
||||||
- **Rule**: Full name "CHARLWOOD, Andrew (Mr)" (surname first, comma-separated). DOB "14/02/1993" (DD/MM/YYYY). NHS No "221 181 0" (GPhC number formatted like NHS number with tooltip). Address "Norwich, NR1". Status "Active" with green dot. Badge "Open to opportunities".
|
**Why:** Data was verified in a prior iteration. Unnecessary changes risk introducing inaccuracies.
|
||||||
- **Why**: This is the most recognizable PMR element. Format must match clinical systems exactly.
|
|
||||||
|
|
||||||
### Keyboard shortcuts
|
## Visual Review Guardrails
|
||||||
- **When**: Implementing navigation
|
|
||||||
- **Rule**: Alt+1 through Alt+7 must activate corresponding sidebar items. Escape closes expanded items and menus. / focuses search. Implement roving tabindex in sidebar (Up/Down arrows navigate, Enter activates).
|
|
||||||
- **Why**: Clinical systems have keyboard shortcuts for rapid navigation. This is expected behavior.
|
|
||||||
|
|
||||||
### Form validation
|
### When: Completing any visual component task (Tasks 1b-11)
|
||||||
- **When**: Building ReferralsView form
|
**Rule:** After quality checks pass, you MUST open the dev server (`http://localhost:5173`) in the browser using Claude in Chrome tools (`tabs_context_mcp`, `navigate`, `computer` with `action: "screenshot"`), take a screenshot of the relevant view, and compare against the reference file spec. Fix any visual discrepancies before committing. If browser tools are unavailable (e.g. Chrome not connected), document this in progress.txt and proceed — do NOT block the iteration.
|
||||||
- **Rule**: Referrer Name and Email are required. Show validation errors if empty on submit. Generate reference number in format REF-YYYY-MM-DD-NNN from current date. Success message shows reference and "Expected response time: 24-48 hours."
|
**Why:** Code review alone cannot catch visual issues. The previous iteration loop produced functionally correct but visually generic output because no one verified the rendered result.
|
||||||
- **Why**: Clinical referral forms have validation. The reference number mimics real NHS referral references.
|
|
||||||
|
|
||||||
### Mobile bottom navigation
|
### When: Browser tools fail or Chrome is not connected
|
||||||
- **When**: Implementing responsive mobile layout
|
**Rule:** If `tabs_context_mcp` or other browser tools fail, skip the visual review step, note it in progress.txt, and continue. Do NOT retry more than twice or spend time debugging browser connectivity.
|
||||||
- **Rule**: On mobile (<768px), sidebar becomes bottom nav bar with 7 icon buttons (56px height, safe area padding). Patient banner becomes minimal. Tables switch to card layout. Add back arrow in each view returning to Summary.
|
**Why:** Visual review is valuable but not blocking. The loop must keep making progress.
|
||||||
- **Why**: Mobile clinical apps use bottom tabs. This matches the NHS App and EMIS Mobile patterns.
|
|
||||||
|
|
||||||
### Search implementation
|
## Technical Guardrails
|
||||||
- **When**: Adding search functionality
|
|
||||||
- **Rule**: Use fuse.js with threshold 0.3. Index all content: consultation titles/bullets, medication names, problem descriptions, investigation names, document titles. Group results by section. Clicking result navigates to view and expands matching item.
|
### When: Writing TypeScript
|
||||||
- **Why**: Clinical systems have record search. Fuse.js provides fuzzy matching for medical record lookups.
|
**Rule:** No `any` types. All props must have typed interfaces. All data must use the types from `src/types/pmr.ts`.
|
||||||
|
**Why:** Strict typing prevents runtime errors and maintains code quality.
|
||||||
|
|
||||||
|
### When: Adding animations
|
||||||
|
**Rule:** All animations must respect `prefers-reduced-motion`. With reduced motion: login typing completes instantly, alerts appear without slide, expand/collapse is instant, banner condensation is instant.
|
||||||
|
**Why:** Accessibility requirement. Users who've opted out of motion must still have a functional experience.
|
||||||
|
|
||||||
|
### When: Building visual components (Tasks 1b-11)
|
||||||
|
**Rule:** Each reference file in `Ralph/refs/` contains a "Design Guidance (from /frontend-design)" section with pre-generated design direction and code patterns. Read this section BEFORE writing code. Do NOT invoke the `/frontend-design` skill at runtime — the guidance is already embedded in the ref files. Follow the aesthetic direction and code patterns provided.
|
||||||
|
**Why:** The design guidance was pre-generated to avoid context overflow. Previous iterations stalled because the skill output consumed the entire context window, leaving no room to write files.
|
||||||
|
|
||||||
|
### When: Running quality checks
|
||||||
|
**Rule:** Run `npm run typecheck`, `npm run lint`, and `npm run build` after EVERY task. Fix all errors before committing.
|
||||||
|
**Why:** Build failures compound across iterations. Fix them immediately.
|
||||||
|
|||||||
+76
-380
@@ -1,397 +1,93 @@
|
|||||||
# Progress Log — Clinical Record PMR Phase
|
# Progress Log
|
||||||
|
|
||||||
## Phase Transition
|
|
||||||
|
|
||||||
**Previous phase completed:** React conversion of ECG Heartbeat CV (all 12 tasks done)
|
|
||||||
**New phase started:** Clinical Record PMR System — Design 7 implementation
|
|
||||||
**Date:** 2026-02-11
|
|
||||||
|
|
||||||
This is a complete redesign of the CV presentation, moving from the ECG animation concept to a Patient Medical Record system interface. All previous components (Hero, Skills, Experience, etc.) will be replaced with PMR-specific views and components.
|
|
||||||
|
|
||||||
## Codebase Patterns
|
## Codebase Patterns
|
||||||
|
|
||||||
### PMR Design System
|
### Project Structure
|
||||||
- **Source of truth**: `designs/07-the-clinical-record.md` — Complete specification for the PMR interface
|
- Components in `src/components/`, views in `src/components/views/`
|
||||||
- **Color palette (light-mode only)**:
|
- Data files in `src/data/` — consultations.ts, medications.ts, problems.ts, investigations.ts, documents.ts, patient.ts
|
||||||
- Main content: `#F5F7FA` (cool light gray)
|
- Types in `src/types/pmr.ts` (PMR interfaces) and `src/types/index.ts` (Phase type)
|
||||||
- Cards: `#FFFFFF` (white)
|
- Hooks in `src/hooks/` — useScrollCondensation.ts, useBreakpoint.ts
|
||||||
- Sidebar: `#1E293B` (dark blue-gray)
|
- Contexts in `src/contexts/` — AccessibilityContext.tsx
|
||||||
- Patient banner: `#334155` (lighter blue-gray)
|
- Path alias: `@/` maps to `./src/`
|
||||||
- NHS blue: `#005EB8` (primary interactive)
|
|
||||||
- Green: `#22C55E` (active/resolved)
|
|
||||||
- Amber: `#F59E0B` (alerts/in-progress)
|
|
||||||
- Red: `#EF4444` (urgent)
|
|
||||||
- Borders: `#E5E7EB` (gray-200)
|
|
||||||
- **Typography**: Inter for general text, Geist Mono for coded entries/data values
|
|
||||||
- **Spacing**: 4px base unit, tighter than previous design (clinical system density)
|
|
||||||
- **Borders**: 1px solid gray-200, 4px radius (clinical systems use minimal rounding)
|
|
||||||
- **Table rows**: 40px height, alternating colors
|
|
||||||
|
|
||||||
### Data Architecture
|
### Phase Management
|
||||||
- All PMR content lives in `src/data/` as typed arrays
|
- App.tsx controls phase: 'boot' -> 'ecg' -> 'login' -> 'pmr'
|
||||||
- Separation of data from components enables easy CV updates
|
- BootSequence.tsx handles terminal animation
|
||||||
- Types defined in `src/types/pmr.ts`
|
- ECGAnimation.tsx handles heartbeat + letter tracing + flatline exit
|
||||||
|
- LoginScreen.tsx bridges to PMRInterface.tsx
|
||||||
|
|
||||||
### Animation Approach
|
### Data Architecture (CORRECT — do not modify)
|
||||||
- **Login typing**: `setInterval` with character-by-character reveal (30ms/char username, 20ms/dot password)
|
- All data files are populated with accurate CV content from References/CV_v4.md
|
||||||
- **View switching**: Instant (no animation) — matches clinical system behavior
|
- 5 consultation entries (roles), 18 medications (skills), 11 problems (achievements), 6 investigations (projects), 5 documents (education)
|
||||||
- **Consultation expand**: Height 0→auto, 200ms, ease-out
|
- Types are properly defined in pmr.ts — Consultation, Medication, Problem, Investigation, Document, Patient, ViewId
|
||||||
- **Alert entrance**: Slide down with spring, 250ms
|
|
||||||
- **Alert dismiss**: Icon → checkmark (200ms) → collapse (200ms)
|
|
||||||
- **Patient banner condensation**: Smooth height transition, 200ms
|
|
||||||
- **Reduced motion**: Typing instant, slides become fades, expand instant
|
|
||||||
|
|
||||||
### Clinical System Authenticity
|
### Design System Requirements (from ref-design-system.md)
|
||||||
- Navigation is instant — no crossfade
|
- Light-mode ONLY — no dark mode
|
||||||
- Tables use explicit borders on all cells
|
- NHS blue: #005EB8 (primary interactive)
|
||||||
- Traffic lights are 8px circles with text labels (never sole indicator)
|
- Border radius: 4px for cards/inputs (clinical systems use minimal rounding)
|
||||||
- Consultation format: History / Examination / Plan (clinical SOAP note structure)
|
- Borders dominate — 1px solid #E5E7EB everywhere
|
||||||
- Medications table mimics actual prescribing lists
|
- Table row height: 40px, card padding: 16px, main content padding: 24px
|
||||||
- Coded entries use [XXX000] format (SNOMED-style)
|
- Fonts: Inter (general text), Geist Mono (coded entries, timestamps, data values)
|
||||||
|
- Base spacing unit: 4px — clinical density, not marketing site spacing
|
||||||
|
|
||||||
### Responsive Breakpoints
|
### Known Dependencies
|
||||||
- Desktop (>1024px): 220px sidebar, full tables
|
- React 18.3.1, TypeScript, Vite
|
||||||
- Tablet (768-1024px): 56px icon-only sidebar, scrollable tables
|
- Tailwind CSS for utility classes
|
||||||
- Mobile (<768px): Bottom nav bar, card layouts instead of tables
|
- Framer Motion 11.15.0 for animations
|
||||||
|
- Lucide React 0.468.0 for icons
|
||||||
|
- fuse.js will need to be installed for Task 12
|
||||||
|
|
||||||
### Accessibility Requirements
|
### Sidebar Label Convention (IMPORTANT)
|
||||||
- Tables must be proper `<table>` markup with `scope="col"`
|
- Sidebar uses CV-friendly labels, NOT clinical jargon
|
||||||
- Clinical alert uses `role="alert"` and `aria-live="assertive"`
|
- Summary (same), Experience (not Consultations), Skills (not Medications), Achievements (not Problems), Projects (not Investigations), Education (not Documents), Contact (not Referrals)
|
||||||
- Keyboard shortcuts: Alt+1-7 for navigation
|
- The clinical metaphor is in the VIEW LAYOUT, not the navigation labels
|
||||||
- Focus management after view changes and expansions
|
- Each view should look like its clinical equivalent but the nav label tells the user what CV section they're looking at
|
||||||
- Screen reader announces views and table structure
|
|
||||||
|
### Visual Review (Claude in Chrome)
|
||||||
|
- Dev server runs on `http://localhost:5173` throughout the loop
|
||||||
|
- Use browser tools (`tabs_context_mcp`, `navigate`, `computer` screenshot) to verify visual output
|
||||||
|
- App has boot→ECG→login→PMR sequence (~15s on first load). Wait before screenshotting.
|
||||||
|
- Once in PMR phase, navigate views via hash routes: `#summary`, `#experience`, `#skills`, `#achievements`, `#projects`, `#education`, `#contact`
|
||||||
|
- If browser tools fail, skip visual review and note in iteration log — don't block progress
|
||||||
|
|
||||||
|
### Critical Styling Notes
|
||||||
|
- Geist Mono font must be loaded (NOT Fira Code) for coded entries and timestamps
|
||||||
|
- Patient banner name must be 20px Inter 600 (not 18px)
|
||||||
|
- Clinical alert must use spring animation (Framer Motion type: "spring"), not ease-out
|
||||||
|
- View switching must be INSTANT — no crossfade, no slide between views
|
||||||
|
- Consultation expand/collapse: height animation ONLY, no opacity fade on content
|
||||||
|
|
||||||
## Iteration Log
|
## Iteration Log
|
||||||
|
|
||||||
### Iteration 1 — Task 1: Create PMR data layer and TypeScript types
|
### Iteration 1 — Task 1: Design system foundation and font setup
|
||||||
- **Completed**: Task 1 - Created PMR data layer with TypeScript interfaces and data files
|
**Completed:** Task 1
|
||||||
- **Files created**:
|
**Changes made:**
|
||||||
- `src/types/pmr.ts` - All PMR TypeScript interfaces (Patient, Consultation, Medication, Problem, Investigation, Document, etc.)
|
- Added Geist Mono font to Google Fonts import in index.html (replacing reliance on Fira Code for PMR components)
|
||||||
- `src/data/consultations.ts` - 5 roles mapped to consultation format with History/Examination/Plan structure
|
- Extended Tailwind config PMR color tokens: added card, text-primary, text-secondary, text-on-dark variants, border colors, selected-row, alert colors
|
||||||
- `src/data/medications.ts` - 18 skills mapped to medication format across 3 categories (Active, Clinical, PRN)
|
- Fixed borderRadius.card from 16px to 4px (clinical system requirement)
|
||||||
- `src/data/problems.ts` - 11 problems with traffic light status (3 Active, 2 In Progress, 6 Resolved)
|
- Added borderRadius.login: 12px (exception for login card per spec)
|
||||||
- `src/data/investigations.ts` - 5 projects as investigations with methodology/results
|
- Added boxShadow.pmr: minimal clinical shadow
|
||||||
- `src/data/documents.ts` - 5 education/certification documents
|
- Added PMR-specific CSS custom properties in index.css (--pmr-* variables)
|
||||||
- `src/data/patient.ts` - Patient demographic data
|
- Added utility classes: .pmr-theme, .font-inter, .font-geist-mono
|
||||||
- **Design decisions**:
|
|
||||||
- Used SNOMED-style codes for coded entries (EFF001, ALG001, AUT001, etc.)
|
|
||||||
- Mapped employer colors: NHS blue (#005EB8) for ICB, Teal (#00897B) for Tesco
|
|
||||||
- Proficiency percentages estimated from CV skill descriptions
|
|
||||||
- Prescribing history for each skill shows progression over time
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- CV has 5 roles but only 4 explicitly listed dates - inferred Duty Pharmacy Manager from GPhC registration date (Aug 2016)
|
|
||||||
- Key numbers verified: £14.6M efficiency, 14,000 patients, £2.6M savings, 70% reduction, 200 hours, £1M revenue, £220M budget
|
|
||||||
- Skills categorized into Active (technical), Clinical (healthcare domain), PRN (strategic/leadership)
|
|
||||||
|
|
||||||
### Iteration 2 — Task 2: Modify ECGAnimation for PMR flatline transition
|
**Codebase patterns discovered:**
|
||||||
- **Completed**: Task 2 - Modified ECGAnimation exit phase for clinical flatline → login transition
|
- The project uses both legacy design tokens (for boot/ECG phases) AND new PMR tokens (for clinical interface) — both need to coexist
|
||||||
- **Files modified**:
|
- Geist Mono is the correct font for coded entries, timestamps, and clinical codes in PMR (NOT Fira Code)
|
||||||
- `src/components/ECGAnimation.tsx` - Changed exit from fade-to-white to flatline → black → login background
|
- Border radius convention: 4px default for clinical components, 12px for login card only, 16px for legacy components
|
||||||
- **Changes made**:
|
- CSS custom properties namespaced with --pmr- prefix for clarity
|
||||||
- Replaced HOLD_TIME (0.75s) and EXIT_TIME (0.8s) with precise phase timings:
|
|
||||||
- FLATLINE_HOLD: 300ms (hold after name trace)
|
|
||||||
- FLATLINE_DRAW: 300ms (horizontal line extending rightward)
|
|
||||||
- FADE_TO_BLACK: 200ms (canvas opacity fade)
|
|
||||||
- BG_TRANSITION: 200ms (background to #1E293B login color)
|
|
||||||
- New timing phases: isFlatlinePhase, isFadePhase, isBgTransitionPhase
|
|
||||||
- Background now transitions to login screen color (#1E293B) instead of white
|
|
||||||
- Flatline drawn from final name position to right edge of viewport
|
|
||||||
- Scanline head dot hidden during fade/bg phases
|
|
||||||
- **Design decisions**:
|
|
||||||
- Flatline visually reads as patient monitor flatline (deliberate metaphor)
|
|
||||||
- Total ECG phase still ~5-6 seconds, exit adds ~1 second
|
|
||||||
- Background transition uses CSS transition for smooth handoff to LoginScreen
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Canvas fade must complete before background transition for clean visual
|
|
||||||
- The flatline extension needs to go slightly past viewport edge (+50px) for smooth visual
|
|
||||||
|
|
||||||
### Iteration 3 — Task 3: Build LoginScreen component with typing animation
|
**Quality checks:** All passed (typecheck, lint, build)
|
||||||
- **Completed**: Task 3 - Created LoginScreen component with authentic clinical login typing animation
|
**Visual review:** N/A (configuration task, no visual component)
|
||||||
- **Files created/modified**:
|
|
||||||
- `src/components/LoginScreen.tsx` - New component with typing animation
|
|
||||||
- `src/App.tsx` - Added 'login' phase between 'ecg' and 'content'
|
|
||||||
- `src/types/index.ts` - Added 'login' to Phase type
|
|
||||||
- `index.html` - Added Inter font family
|
|
||||||
- `tailwind.config.js` - Added PMR colors (sidebar, banner, nhsblue, etc.) and fonts (inter, geist)
|
|
||||||
- **Design decisions**:
|
|
||||||
- Username types at 30ms per character (A.CHARLWOOD = 11 chars + space = ~350ms)
|
|
||||||
- Password fills 8 dots at 20ms per dot (~160ms)
|
|
||||||
- Button shows pressed state (darker, scale) before onComplete callback
|
|
||||||
- Blinking cursor at 530ms interval during typing
|
|
||||||
- Uses Fira Code as monospace font (Geist Mono not available via Google Fonts)
|
|
||||||
- NHS blue shield icon for clinical system branding
|
|
||||||
- White login card: 320px wide, 12px radius, subtle shadow
|
|
||||||
- **Accessibility**:
|
|
||||||
- Respects prefers-reduced-motion: instant text appearance, ~500ms total
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Geist Mono not available via Google Fonts, using Fira Code as fallback
|
|
||||||
- Total login animation timing: ~1.2s (350ms username + 150ms pause + 160ms password + 150ms pause + 100ms button + 200ms hold)
|
|
||||||
|
|
||||||
### Iteration 4 — Task 4: Build PatientBanner component with full and condensed modes
|
**Issues encountered:** None
|
||||||
- **Completed**: Task 4 - Created PatientBanner component with scroll-based condensation
|
|
||||||
- **Files created**:
|
|
||||||
- `src/hooks/useScrollCondensation.ts` - IntersectionObserver hook for scroll detection
|
|
||||||
- `src/components/PatientBanner.tsx` - Full (80px) and condensed (48px) banner modes
|
|
||||||
- **Design decisions**:
|
|
||||||
- IntersectionObserver with rootMargin -100px to detect scroll past threshold
|
|
||||||
- Smooth height transition using CSS `transition-all duration-200 ease-out`
|
|
||||||
- Sticky positioning with z-40 for persistent visibility
|
|
||||||
- Status dot: 8px circle, green for Active
|
|
||||||
- Badge: NHS blue background, white text, small pill shape
|
|
||||||
- Action buttons: outlined with NHS blue, fill on hover
|
|
||||||
- GPhC number formatted with spaces like NHS number (221 181 0)
|
|
||||||
- Tooltip on NHS No field explaining it's GPhC Registration Number
|
|
||||||
- **Accessibility**:
|
|
||||||
- `role="banner"` on header element
|
|
||||||
- `aria-label` on status dot
|
|
||||||
- Proper link semantics for phone and email
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- IntersectionObserver with rootMargin provides cleaner scroll detection than scroll event listeners
|
|
||||||
- Sentinel element at top of viewport triggers condensation when it leaves view
|
|
||||||
- Sticky positioning requires no JavaScript for the sticky behavior itself
|
|
||||||
|
|
||||||
### Iteration 5 — Task 5: Build ClinicalSidebar component with navigation and search
|
**Design decisions:**
|
||||||
- **Completed**: Task 5 - Created ClinicalSidebar with navigation and search functionality
|
- Kept legacy tokens in place to avoid breaking boot/ECG components
|
||||||
- **Files created**:
|
- Used --pmr- namespace for all PMR tokens to distinguish from legacy design system
|
||||||
- `src/components/ClinicalSidebar.tsx` - Sidebar navigation with 7 items
|
- Extended Tailwind colors rather than replacing them — allows both themes to work simultaneously
|
||||||
- `src/components/PMRInterface.tsx` - Main PMR layout container
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/App.tsx` - Changed from 'content' to 'pmr' phase, uses PMRInterface
|
|
||||||
- `src/types/index.ts` - Updated Phase type: 'content' → 'pmr'
|
|
||||||
- **Design decisions**:
|
|
||||||
- 220px fixed width sidebar with dark blue-gray background (#1E293B)
|
|
||||||
- Header: "CareerRecord PMR v1.0.0" in 50% opacity white
|
|
||||||
- 7 navigation items with Lucide icons (ClipboardList, FileText, Pill, AlertTriangle, FlaskConical, FolderOpen, Send)
|
|
||||||
- Separator line between Summary and Consultations
|
|
||||||
- Active state: 3px NHS blue left border, white text, background rgba(255,255,255,0.12)
|
|
||||||
- Hover state: white text at 100%, background rgba(255,255,255,0.08)
|
|
||||||
- Search input in header with basic filtering (fuse.js not installed yet)
|
|
||||||
- Footer with "Session: A.CHARLWOOD" and current time (updates every minute)
|
|
||||||
- URL hash routing (#summary, #consultations, etc.)
|
|
||||||
- Keyboard shortcuts: Alt+1-7 for navigation, / to focus search, Escape to clear search
|
|
||||||
- **Navigation behavior**:
|
|
||||||
- Instant view switching (no animation) — matches clinical system authenticity
|
|
||||||
- Click updates URL hash and activeView state simultaneously
|
|
||||||
- On page load, hash is read to set initial view
|
|
||||||
- **Accessibility**:
|
|
||||||
- `role="navigation"` and `aria-label` on sidebar
|
|
||||||
- `aria-current="page"` on active nav item
|
|
||||||
- Keyboard navigation with Alt+1-7 shortcuts
|
|
||||||
- Search has escape key to clear and blur
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Lucide React already installed, provides all required icons
|
|
||||||
- fuse.js not installed — basic search filtering implemented, can enhance later
|
|
||||||
- Sticky positioning with `h-screen sticky top-0` keeps sidebar fixed while content scrolls
|
|
||||||
- PMRInterface wraps PatientBanner + sidebar + main content layout
|
|
||||||
|
|
||||||
### Iteration 6 — Task 6: Build SummaryView component with clinical alert
|
### IMPORTANT — Design Guidance is Pre-Baked
|
||||||
- **Completed**: Task 6 - Created SummaryView with Clinical Alert and summary cards
|
Do NOT invoke the `/frontend-design` skill at runtime — it was pre-run and the output is embedded in each ref file under "Design Guidance (from /frontend-design)". Previous iterations STALLED because the skill output consumed the entire context window. The guidance is now in the ref files — just read and implement.
|
||||||
- **Files created**:
|
|
||||||
- `src/components/views/SummaryView.tsx` - Full Summary view with Clinical Alert and 4 cards
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/components/PMRInterface.tsx` - Updated to render SummaryView when activeView is 'summary'
|
|
||||||
- **Design decisions**:
|
|
||||||
- Clinical Alert: amber background (#FEF3C7), 4px amber left border, AlertTriangle icon
|
|
||||||
- Alert animates in with max-height transition (300ms delay after view loads)
|
|
||||||
- Acknowledge button: on click, icon cross-fades to green Check (200ms), then alert collapses (200ms)
|
|
||||||
- Patient Demographics card: full width, two-column key-value layout with right-aligned labels
|
|
||||||
- Active Problems card: shows 3 active/in-progress problems with traffic light dots and dates
|
|
||||||
- Current Medications Quick View: 4-column table (Drug, Dose, Freq, Status), top 5 Active meds
|
|
||||||
- Last Consultation card: shows most recent role with truncated history text
|
|
||||||
- Traffic lights: 8px circles, green for Active/Resolved, amber for In Progress
|
|
||||||
- All tables use proper semantic `<table>` markup with `scope="col"`
|
|
||||||
- "View Full List" / "View Full Record" links navigate to corresponding views
|
|
||||||
- **Accessibility**:
|
|
||||||
- `role="alert"` and `aria-live="assertive"` on Clinical Alert
|
|
||||||
- `aria-label` on main content area with current view name
|
|
||||||
- Proper table semantics for medications table
|
|
||||||
- Traffic lights always accompanied by text labels (never sole indicator)
|
|
||||||
- Respects `prefers-reduced-motion`: alert appears instantly, no animations
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Alert animation uses max-height transition for smooth expand/collapse
|
|
||||||
- Clinical Alert text uses amber-800 (#92400E) for contrast against amber-100 background
|
|
||||||
- Grid layout: demographics full width, problems/medications side-by-side, last consultation full width
|
|
||||||
- `line-clamp-2` and `line-clamp-3` utilities work well for truncating text in cards
|
|
||||||
|
|
||||||
### Iteration 7 — Task 7: Build ConsultationsView with History/Examination/Plan structure
|
|
||||||
- **Completed**: Task 7 - Created ConsultationsView with expandable consultation entries
|
|
||||||
- **Files created**:
|
|
||||||
- `src/components/views/ConsultationsView.tsx` - Full Consultations view with 5 expandable entries
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/components/PMRInterface.tsx` - Added ConsultationsView to renderView switch
|
|
||||||
- **Design decisions**:
|
|
||||||
- Each entry has 3px left border color-coded by employer: NHS blue (#005EB8) for ICB, Teal (#00897B) for Tesco
|
|
||||||
- Collapsed state shows: status dot, date, organization (colored), role title, key coded entry summary
|
|
||||||
- Status dot: green for current roles, gray for historical
|
|
||||||
- Expanded state shows: Duration, HISTORY (paragraph), EXAMINATION (bullets), PLAN (bullets), CODED ENTRIES
|
|
||||||
- Section headers styled in Inter 600, 12px, uppercase, tracking-wider, gray-400
|
|
||||||
- Coded entries use [XXX000] format in Geist Mono, gray-400
|
|
||||||
- Only one entry expanded at a time (accordion behavior)
|
|
||||||
- Expand animation: height 0→auto (200ms, ease-out)
|
|
||||||
- Chevron icon rotates 180° when expanded
|
|
||||||
- **Accessibility**:
|
|
||||||
- `aria-expanded` on toggle buttons
|
|
||||||
- Status dots have `aria-label` describing current vs historical
|
|
||||||
- Respects `prefers-reduced-motion`: expand is instant
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Height animation uses `height: auto` which requires setting height to undefined after animation
|
|
||||||
- Content inside expanded area uses separate opacity transition for smooth appearance
|
|
||||||
- Border-left styling with explicit width/color in style prop for dynamic org colors
|
|
||||||
|
|
||||||
### Iteration 8 — Task 8: Build MedicationsView with sortable table and prescribing history
|
|
||||||
- **Completed**: Task 8 - Created MedicationsView with sortable table and category tabs
|
|
||||||
- **Files created**:
|
|
||||||
- `src/components/views/MedicationsView.tsx` - Full Medications view with 3 category tabs
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/components/PMRInterface.tsx` - Added MedicationsView to renderView switch
|
|
||||||
- `src/index.css` - Added fadeIn animation for expanded content
|
|
||||||
- **Design decisions**:
|
|
||||||
- Three category tabs: Active Medications (technical), Clinical Medications (healthcare), PRN (strategic)
|
|
||||||
- Tabs have descriptive subtitles to explain the mapping
|
|
||||||
- Sortable columns: clicking header toggles asc/desc/null with visual indicators
|
|
||||||
- Sort icons: ArrowUpDown (unsorted), ArrowUp (asc), ArrowDown (desc) in NHS blue
|
|
||||||
- Table columns: Drug Name, Dose (%), Frequency, Start (year), Status
|
|
||||||
- Row height: ~40px with 10px py padding
|
|
||||||
- Alternating row colors: white / gray-50
|
|
||||||
- Hover state: blue-50 (#EFF6FF) tint
|
|
||||||
- Traffic light status dots: 8px circles (green=Active, gray=Historical) with text labels
|
|
||||||
- Expandable rows: click to show "Prescribing History" mini-timeline
|
|
||||||
- Prescribing history: year + description, styled in Geist Mono
|
|
||||||
- fadeIn animation (200ms ease-out) for expanded content
|
|
||||||
- **Accessibility**:
|
|
||||||
- Proper semantic `<table>` markup with `scope="col"` on headers
|
|
||||||
- `role="tablist"` and `aria-selected` on tab buttons
|
|
||||||
- `aria-expanded` on expandable rows
|
|
||||||
- Traffic lights always accompanied by text labels
|
|
||||||
- Respects `prefers-reduced-motion`: fadeIn disabled
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Case block lexical declarations need curly braces wrapping to satisfy ESLint
|
|
||||||
- Sort state with three values (null/asc/desc) provides intuitive toggle behavior
|
|
||||||
- Frequency sort uses custom order object mapping
|
|
||||||
|
|
||||||
### Iteration 9 — Task 9: Build ProblemsView with traffic light system
|
|
||||||
- **Completed**: Task 9 - Created ProblemsView with two tables and expandable narrative
|
|
||||||
- **Files created**:
|
|
||||||
- `src/components/views/ProblemsView.tsx` - Full Problems view with Active and Resolved sections
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/components/PMRInterface.tsx` - Added ProblemsView to renderView switch
|
|
||||||
- **Design decisions**:
|
|
||||||
- Two sections: Active Problems (3 items) and Resolved Problems (8 items)
|
|
||||||
- Traffic light component: 8px circles with text labels (green=Active/Resolved, amber=In Progress)
|
|
||||||
- Active Problems table: Status, Code, Problem, Since columns
|
|
||||||
- Resolved Problems table: Status, Code, Problem, Resolved, Outcome columns
|
|
||||||
- Code column: [XXX000] format in Geist Mono, gray-500
|
|
||||||
- Expandable rows: click to show narrative and linked consultations
|
|
||||||
- Linked consultations: clickable buttons navigate to Consultations view with item ID
|
|
||||||
- Height animation: 200ms ease-out for expand/collapse
|
|
||||||
- Hover state: blue-50 (#EFF6FF) background tint
|
|
||||||
- Accordion behavior: only one row expanded at a time (per section? globally? went with global)
|
|
||||||
- **Accessibility**:
|
|
||||||
- Proper semantic `<table>` markup with `scope="col"` on headers
|
|
||||||
- `aria-expanded` on clickable rows
|
|
||||||
- Traffic lights have `aria-label` with status text
|
|
||||||
- Expand button has `aria-label` for screen readers
|
|
||||||
- Screen reader-only column header for expand button
|
|
||||||
- Respects `prefers-reduced-motion`: height transition disabled
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- `problem.linkedConsultations` needed null coalescing since it's optional in the type
|
|
||||||
- Height animation uses refs to measure content height for smooth expansion
|
|
||||||
- Linked consultations use external link icon to indicate navigation
|
|
||||||
|
|
||||||
### Iteration 10 — Task 10: Build InvestigationsView with results panel
|
|
||||||
- **Completed**: Task 10 - Created InvestigationsView with expandable rows and results panel
|
|
||||||
- **Files created**:
|
|
||||||
- `src/components/views/InvestigationsView.tsx` - Full Investigations view with 5 project entries
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/components/PMRInterface.tsx` - Added InvestigationsView to renderView switch
|
|
||||||
- **Design decisions**:
|
|
||||||
- Status badges: Complete (green dot), Ongoing (amber dot), Live (pulsing green dot with ping animation)
|
|
||||||
- Table columns: Test Name, Requested, Status, Result
|
|
||||||
- Expandable rows: click to show tree-indented results panel
|
|
||||||
- Results panel uses key-value layout with fixed-width labels (w-40) for alignment
|
|
||||||
- Methodology and Results sections display multi-line content
|
|
||||||
- Tech Stack displayed as comma-separated list
|
|
||||||
- PharMetrics has "View Results" button linking to medicines.charlwood.xyz
|
|
||||||
- Height animation: 200ms ease-out for expand/collapse
|
|
||||||
- Accordion behavior: only one row expanded at a time (global)
|
|
||||||
- Hover state: blue-50 (#EFF6FF) background tint
|
|
||||||
- **Accessibility**:
|
|
||||||
- Proper semantic `<table>` markup with `scope="col"` on headers
|
|
||||||
- `aria-expanded` on clickable rows
|
|
||||||
- Status badges have `aria-label` with status text
|
|
||||||
- Traffic lights always accompanied by text labels
|
|
||||||
- External link has proper target="_blank" rel="noopener noreferrer"
|
|
||||||
- Respects `prefers-reduced-motion`: height transition disabled
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Live status uses Tailwind animate-ping for pulsing effect (requires relative container)
|
|
||||||
- Tree-indented structure uses flex with fixed-width labels for clean alignment
|
|
||||||
- Results as bullet list provides better readability than comma-separated text
|
|
||||||
|
|
||||||
### Iteration 11 — Task 11: Build DocumentsView for education/certifications
|
|
||||||
- **Completed**: Task 11 - Created DocumentsView with expandable rows and preview panel
|
|
||||||
- **Files created**:
|
|
||||||
- `src/components/views/DocumentsView.tsx` - Full Documents view with 5 document entries
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/components/PMRInterface.tsx` - Added DocumentsView to renderView switch
|
|
||||||
- **Design decisions**:
|
|
||||||
- Document type icons: FileText (Certificate), Award (Registration), GraduationCap (Results), FlaskConical (Research)
|
|
||||||
- Table columns: Type (icon), Document, Date, Source
|
|
||||||
- Expandable rows: click to show tree-indented preview panel
|
|
||||||
- Preview panel uses key-value layout with fixed-width labels (w-40) for alignment
|
|
||||||
- Shows: Type, Date Awarded, Institution, Classification, Duration, Research (with grade), Notes
|
|
||||||
- Height animation: 200ms ease-out for expand/collapse
|
|
||||||
- Accordion behavior: only one row expanded at a time (global)
|
|
||||||
- Hover state: blue-50 (#EFF6FF) background tint
|
|
||||||
- Consistent with InvestigationsView expanded view style
|
|
||||||
- **Accessibility**:
|
|
||||||
- Proper semantic `<table>` markup with `scope="col"` on headers
|
|
||||||
- `aria-expanded` on clickable rows
|
|
||||||
- Screen reader-only column header for expand button
|
|
||||||
- Respects `prefers-reduced-motion`: height transition disabled
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Document type icons from Lucide match design spec exactly
|
|
||||||
- Research documents show both detail and grade in same field with line break
|
|
||||||
- Tree-indented structure consistency across Investigations and Documents views
|
|
||||||
|
|
||||||
### Iteration 12 — Task 12: Build ReferralsView with clinical referral form
|
|
||||||
- **Completed**: Task 12 - Created ReferralsView with clinical referral form and direct contact section
|
|
||||||
- **Files created**:
|
|
||||||
- `src/components/views/ReferralsView.tsx` - Full Referrals view with form and contact table
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/components/PMRInterface.tsx` - Added ReferralsView to renderView switch
|
|
||||||
- **Design decisions**:
|
|
||||||
- Form layout: two-column grid for patient info and referrer fields
|
|
||||||
- Pre-filled patient info: CHARLWOOD, Andrew (Mr); NHS Number: 221 181 0
|
|
||||||
- Priority radio buttons: Urgent (red), Routine (NHS blue, default), Two-Week Wait (amber)
|
|
||||||
- Tongue-in-cheek tooltips on priority options
|
|
||||||
- Form fields: Referrer Name (required), Referrer Email (required, validated), Referrer Org (optional), Reason (textarea)
|
|
||||||
- Contact method radio: Email, Phone, LinkedIn
|
|
||||||
- Submit button: NHS blue (#005EB8), loading spinner on submit
|
|
||||||
- Success state: green checkmark, reference number (REF-YYYY-MMDD-NNN), 24-48hr response message
|
|
||||||
- Direct Contact table below form: Email (mailto), Phone (tel), LinkedIn (external link), Location
|
|
||||||
- Form validation with inline error messages
|
|
||||||
- Input styling: 1px border-gray-300, 4px radius, 8px 12px padding, NHS blue focus ring
|
|
||||||
- **Accessibility**:
|
|
||||||
- Proper form labels with `htmlFor` associations
|
|
||||||
- Required field indicators (red asterisk)
|
|
||||||
- Error messages announced
|
|
||||||
- Radio buttons properly grouped with hidden inputs and visible styled indicators
|
|
||||||
- Respects `prefers-reduced-motion`: no animations
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Form validation uses simple state-based approach with error objects
|
|
||||||
- Radio buttons styled with hidden `<input>` and custom styled `<span>` for visual control
|
|
||||||
- Reference number uses current date plus random sequence for uniqueness
|
|
||||||
- Direct Contact table uses same key-value layout as Patient Demographics card
|
|
||||||
|
|
||||||
|
### ECG Reference Implementation
|
||||||
|
`ECGCombined.tsx` in the project root is a Remotion version of the ECG animation with a superior mask-based text reveal technique. Task 1b references this for the canvas implementation.
|
||||||
|
|||||||
+41
-11
@@ -15,7 +15,8 @@
|
|||||||
- Same error repeated N consecutive iterations (stuck)
|
- Same error repeated N consecutive iterations (stuck)
|
||||||
|
|
||||||
.PARAMETER Model
|
.PARAMETER Model
|
||||||
Claude model to use. Default: "opus".
|
Initial Claude model to use. Default: "sonnet". The agent can dynamically switch
|
||||||
|
models between iterations via <next-model>opus|sonnet</next-model> signals.
|
||||||
|
|
||||||
.PARAMETER BranchName
|
.PARAMETER BranchName
|
||||||
Optional git branch name. If provided, creates/checks out the branch before starting.
|
Optional git branch name. If provided, creates/checks out the branch before starting.
|
||||||
@@ -34,7 +35,7 @@
|
|||||||
#>
|
#>
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[string]$Model = "opus",
|
[string]$Model = "sonnet",
|
||||||
[string]$BranchName,
|
[string]$BranchName,
|
||||||
[int]$MaxNoProgress = 3,
|
[int]$MaxNoProgress = 3,
|
||||||
[int]$MaxSameError = 3
|
[int]$MaxSameError = 3
|
||||||
@@ -142,13 +143,40 @@ if (Test-Path $progressFile) {
|
|||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "===== Ralph Wiggum Loop (Visualization Improvements) =====" -ForegroundColor Cyan
|
Write-Host "===== Ralph Wiggum Loop (Visualization Improvements) =====" -ForegroundColor Cyan
|
||||||
Write-Host "Model: $Model | Runs until COMPLETE" -ForegroundColor Cyan
|
Write-Host "Model: $Model (dynamic switching enabled) | Visual review: ON | Runs until COMPLETE" -ForegroundColor Cyan
|
||||||
Write-Host "Circuit breakers: no-progress=$MaxNoProgress, same-error=$MaxSameError" -ForegroundColor Cyan
|
Write-Host "Circuit breakers: no-progress=$MaxNoProgress, same-error=$MaxSameError" -ForegroundColor Cyan
|
||||||
if ($BranchName) { Write-Host "Branch: $BranchName" -ForegroundColor Cyan }
|
if ($BranchName) { Write-Host "Branch: $BranchName" -ForegroundColor Cyan }
|
||||||
if ($existingIterations -gt 0) { Write-Host "Previous iterations: $existingIterations" -ForegroundColor Cyan }
|
if ($existingIterations -gt 0) { Write-Host "Previous iterations: $existingIterations" -ForegroundColor Cyan }
|
||||||
Write-Host "===========================================" -ForegroundColor Cyan
|
Write-Host "===========================================" -ForegroundColor Cyan
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
|
# --- Dev Server (for visual review via Claude in Chrome) ---
|
||||||
|
$devServerPort = 5173
|
||||||
|
$devServerPid = $null
|
||||||
|
|
||||||
|
try {
|
||||||
|
$null = Invoke-WebRequest -Uri "http://localhost:$devServerPort" -TimeoutSec 2 -ErrorAction Stop
|
||||||
|
Write-Host "Dev server detected on port $devServerPort" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host "Starting dev server (port $devServerPort)..." -ForegroundColor Cyan
|
||||||
|
$devProc = Start-Process -FilePath "npm.cmd" -ArgumentList "run", "dev" -PassThru -WindowStyle Minimized
|
||||||
|
$devServerPid = $devProc.Id
|
||||||
|
|
||||||
|
for ($w = 1; $w -le 20; $w++) {
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
try {
|
||||||
|
$null = Invoke-WebRequest -Uri "http://localhost:$devServerPort" -TimeoutSec 2 -ErrorAction Stop
|
||||||
|
Write-Host "Dev server ready on port $devServerPort" -ForegroundColor Green
|
||||||
|
break
|
||||||
|
} catch {
|
||||||
|
if ($w -eq 20) {
|
||||||
|
Write-Warning "Dev server may not be ready - visual review steps may fail"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
$i = 0
|
$i = 0
|
||||||
while ($true) {
|
while ($true) {
|
||||||
$i++
|
$i++
|
||||||
@@ -306,6 +334,7 @@ while ($true) {
|
|||||||
Write-Host "===== CIRCUIT BREAKER: NO PROGRESS =====" -ForegroundColor Red
|
Write-Host "===== CIRCUIT BREAKER: NO PROGRESS =====" -ForegroundColor Red
|
||||||
Write-Host "No git commits for $MaxNoProgress consecutive iterations. The loop is stalled." -ForegroundColor Red
|
Write-Host "No git commits for $MaxNoProgress consecutive iterations. The loop is stalled." -ForegroundColor Red
|
||||||
Write-Host "Check progress.txt and logs/ for details on what went wrong." -ForegroundColor Red
|
Write-Host "Check progress.txt and logs/ for details on what went wrong." -ForegroundColor Red
|
||||||
|
if ($devServerPid) { taskkill /T /F /PID $devServerPid 2>$null | Out-Null }
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -326,6 +355,7 @@ while ($true) {
|
|||||||
Write-Host "Same error pattern for $MaxSameError consecutive iterations:" -ForegroundColor Red
|
Write-Host "Same error pattern for $MaxSameError consecutive iterations:" -ForegroundColor Red
|
||||||
Write-Host " $currentErrorSignature" -ForegroundColor Red
|
Write-Host " $currentErrorSignature" -ForegroundColor Red
|
||||||
Write-Host "Check progress.txt and logs/ for details." -ForegroundColor Red
|
Write-Host "Check progress.txt and logs/ for details." -ForegroundColor Red
|
||||||
|
if ($devServerPid) { taskkill /T /F /PID $devServerPid 2>$null | Out-Null }
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
} elseif ($currentErrorSignature) {
|
} elseif ($currentErrorSignature) {
|
||||||
@@ -337,15 +367,14 @@ while ($true) {
|
|||||||
$lastErrorSignature = ""
|
$lastErrorSignature = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Push to Remote ---
|
# --- Dynamic Model Selection ---
|
||||||
$hasRemote = git remote 2>$null
|
if ($outputString -match "<next-model>(opus|sonnet)</next-model>") {
|
||||||
if ($hasRemote) {
|
$nextModel = $Matches[1]
|
||||||
$currentBranch = git branch --show-current
|
if ($nextModel -ne $Model) {
|
||||||
git push origin $currentBranch 2>$null
|
Write-Host " [Model Switch] $Model -> $nextModel (agent recommendation)" -ForegroundColor Magenta
|
||||||
if ($LASTEXITCODE -eq 0) {
|
$Model = $nextModel
|
||||||
Write-Host " Pushed to remote." -ForegroundColor Green
|
|
||||||
} else {
|
} else {
|
||||||
Write-Host " Push failed or no remote configured - continuing." -ForegroundColor DarkYellow
|
Write-Host " [Model] Staying on $Model" -ForegroundColor DarkGray
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +383,7 @@ while ($true) {
|
|||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "===== COMPLETE =====" -ForegroundColor Green
|
Write-Host "===== COMPLETE =====" -ForegroundColor Green
|
||||||
Write-Host "Visualization improvements finished after $i iteration(s) this run ($totalIteration total)." -ForegroundColor Green
|
Write-Host "Visualization improvements finished after $i iteration(s) this run ($totalIteration total)." -ForegroundColor Green
|
||||||
|
if ($devServerPid) { taskkill /T /F /PID $devServerPid 2>$null | Out-Null }
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Reference: Patient Banner + Sidebar + Navigation
|
||||||
|
|
||||||
|
> Extracted from goal.md — Patient Banner, Left Sidebar, and Navigation sections. These are the persistent UI chrome that defines the clinical system feel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Patient Banner (Persistent Top Chrome)
|
||||||
|
|
||||||
|
The patient banner is the most recognizable element of any PMR system. It spans the full viewport width above the main content area and provides constant demographic context.
|
||||||
|
|
||||||
|
### Full Banner (80px height, visible at top of page)
|
||||||
|
|
||||||
|
```
|
||||||
|
+---------------------------------------------------------------------------+
|
||||||
|
| CHARLWOOD, Andrew (Mr) Active (green dot) Open to opps. |
|
||||||
|
| DOB: 14/02/1993 | NHS No: 2211810 | Norwich, NR1 |
|
||||||
|
| 07795553088 | andy@charlwood.xyz [Download CV] [Email] [LinkedIn] |
|
||||||
|
+---------------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content Mapping
|
||||||
|
|
||||||
|
| PMR Field | Actual Content | Notes |
|
||||||
|
|-----------|---------------|-------|
|
||||||
|
| Patient name | CHARLWOOD, Andrew (Mr) | Surname first, comma-separated — exactly as in clinical systems |
|
||||||
|
| DOB | 14/02/1993 | DD/MM/YYYY format (UK clinical standard) |
|
||||||
|
| NHS Number | 221 181 0 | Andy's GPhC registration number formatted like an NHS number (with spaces). Hover tooltip: "GPhC Registration Number" |
|
||||||
|
| GP Practice | Self-Referred | Tongue-in-cheek — Andy referred himself to this record |
|
||||||
|
| Address | Norwich, NR1 | Abbreviated postcode area |
|
||||||
|
| Phone | 07795553088 | Clickable (tel: link) |
|
||||||
|
| Email | andy@charlwood.xyz | Clickable (mailto: link) |
|
||||||
|
| Status | Active (green dot) | Like the "registered" status in a PMR |
|
||||||
|
| Badge | Open to opportunities | Styled as a clinical banner tag (blue background, white text, small pill shape) |
|
||||||
|
|
||||||
|
### Action Buttons (top right of banner)
|
||||||
|
|
||||||
|
| Button | PMR Equivalent | Action |
|
||||||
|
|--------|---------------|--------|
|
||||||
|
| Download CV | Print Summary | Downloads PDF version of CV |
|
||||||
|
| Email | Send Letter | Opens mailto: link |
|
||||||
|
| LinkedIn | External Link | Opens LinkedIn profile in new tab |
|
||||||
|
|
||||||
|
Buttons are styled as small outlined rectangles with NHS blue text and 1px NHS blue border, 4px radius. On hover: filled NHS blue background with white text.
|
||||||
|
|
||||||
|
### Condensed Banner (48px, sticky after scroll)
|
||||||
|
|
||||||
|
When the user scrolls past 100px of content, the banner smoothly condenses to show only the essential information on a single line:
|
||||||
|
|
||||||
|
```
|
||||||
|
CHARLWOOD, Andrew (Mr) | NHS No: 2211810 | Active (green dot) [Download CV] [Email]
|
||||||
|
```
|
||||||
|
|
||||||
|
The condensed banner sticks to the top of the viewport (`position: sticky`) with a `z-index` above the content area but below modals/alerts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Left Sidebar — Clinical Navigation
|
||||||
|
|
||||||
|
The sidebar replicates the dark navigation panel found in EMIS Web and similar clinical systems. It provides category-based access to different "record views."
|
||||||
|
|
||||||
|
**Width:** 220px (desktop), dark blue-gray (`#1E293B`) background.
|
||||||
|
|
||||||
|
### Navigation Items
|
||||||
|
|
||||||
|
**IMPORTANT:** Sidebar labels use CV-friendly terms, NOT clinical jargon. The clinical metaphor lives in the LAYOUT of each view, not the labels.
|
||||||
|
|
||||||
|
| Icon | Label | View Layout Style | Description |
|
||||||
|
|------|-------|-------------------|-------------|
|
||||||
|
| `ClipboardList` | Summary | Patient summary screen | Demographics, active items, current skills, recent role |
|
||||||
|
| `FileText` | Experience | Consultation journal layout | Reverse-chronological journal of roles with H/E/P format |
|
||||||
|
| `Pill` | Skills | Medications table layout | Skills table with proficiency dosages and frequency |
|
||||||
|
| `AlertTriangle` | Achievements | Problems list layout | Challenges resolved and ongoing, with traffic lights |
|
||||||
|
| `FlaskConical` | Projects | Investigation results layout | Project outcomes with status badges |
|
||||||
|
| `FolderOpen` | Education | Attached documents layout | Certificates and qualifications |
|
||||||
|
| `Send` | Contact | Referral form layout | Contact/message form styled as clinical referral |
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
- Each item: 44px height, 16px left padding, icon (18px, `lucide-react`) + label in Inter 500, 14px
|
||||||
|
- Default state: white text at 70% opacity, transparent background
|
||||||
|
- Hover state: white text at 100% opacity, background `rgba(255,255,255,0.08)`
|
||||||
|
- Active state: white text at 100%, NHS blue left border (3px), background `rgba(255,255,255,0.12)`, label in Inter 600
|
||||||
|
- A thin horizontal separator line (`1px solid rgba(255,255,255,0.1)`) appears between "Summary" and "Consultations" (separating the overview from the detail views)
|
||||||
|
|
||||||
|
### Sidebar Footer
|
||||||
|
|
||||||
|
At the bottom of the sidebar, in small text (Inter 400, 11px, `#64748B`):
|
||||||
|
```
|
||||||
|
Session: A.CHARLWOOD
|
||||||
|
Logged in: [current time]
|
||||||
|
```
|
||||||
|
This updates with the actual current time on mount, reinforcing the "logged in" metaphor.
|
||||||
|
|
||||||
|
### Sidebar Header
|
||||||
|
|
||||||
|
At the top, above the navigation items, a small logo or system name:
|
||||||
|
```
|
||||||
|
CareerRecord PMR
|
||||||
|
v1.0.0
|
||||||
|
```
|
||||||
|
In Inter 500, 13px, white at 50% opacity. Styled like the "EMIS Web" branding that appears in the top-left of the real system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
### Primary Navigation: Left Sidebar
|
||||||
|
|
||||||
|
The sidebar is always visible on desktop — this is how clinical systems work. There is no floating nav, no hamburger menu on desktop, and no scroll-based navigation. The sidebar provides persistent, direct access to any record section.
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
| Sidebar Item | View Layout | Shortcut |
|
||||||
|
|-------------|-------------|----------|
|
||||||
|
| Summary | Patient summary | `Alt+1` |
|
||||||
|
| Experience | Consultation journal | `Alt+2` |
|
||||||
|
| Skills | Medications table | `Alt+3` |
|
||||||
|
| Achievements | Problems list | `Alt+4` |
|
||||||
|
| Projects | Investigation results | `Alt+5` |
|
||||||
|
| Education | Attached documents | `Alt+6` |
|
||||||
|
| Contact | Referral form | `Alt+7` |
|
||||||
|
|
||||||
|
### URL Hash Routing
|
||||||
|
|
||||||
|
Each sidebar item updates the URL hash (`#summary`, `#experience`, `#skills`, `#achievements`, `#projects`, `#education`, `#contact`) for direct linking. On page load, the app reads the hash and navigates to the corresponding view.
|
||||||
|
|
||||||
|
### Breadcrumb
|
||||||
|
|
||||||
|
A breadcrumb appears at the top of the main content area:
|
||||||
|
|
||||||
|
```
|
||||||
|
Patient Record > Consultations > Interim Head, Population Health
|
||||||
|
```
|
||||||
|
|
||||||
|
The breadcrumb updates as the user navigates deeper (e.g., expanding a consultation). Clicking "Patient Record" returns to Summary. Clicking "Consultations" collapses any expanded entries and shows the full journal list. The breadcrumb is styled in Inter 400, 13px, gray-400, with chevron separators.
|
||||||
|
|
||||||
|
### Secondary Navigation: Within-View Interactions
|
||||||
|
|
||||||
|
- **Summary:** Clicking "View Full List" or "View Full Record" links navigates to the corresponding sidebar section.
|
||||||
|
- **Consultations:** Expand/collapse individual entries. "Linked consultations" in Problems view can deep-link to specific consultation entries.
|
||||||
|
- **Medications:** Category tabs (Active, Clinical, PRN) within the view. Click to expand prescribing history.
|
||||||
|
- **Problems:** Click to expand. "Linked consultations" navigate to Consultations view.
|
||||||
|
- **Investigations:** Click to expand results.
|
||||||
|
- **Documents:** Click to expand preview.
|
||||||
|
- **Referrals:** No sub-navigation.
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
# Reference: Boot Sequence + ECG Animation
|
||||||
|
|
||||||
|
> Covers the full pre-login flow: terminal boot → cursor transition → ECG heartbeat → name reveal → flatline. The flatline→login transition is covered in `ref-transition-login.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Architecture
|
||||||
|
|
||||||
|
Two components manage the pre-login flow:
|
||||||
|
- `src/components/BootSequence.tsx` → terminal text animation, ends with blinking cursor
|
||||||
|
- `src/components/ECGAnimation.tsx` → canvas-based heartbeat + name tracing + flatline + bg transition
|
||||||
|
- `App.tsx` phases: `boot → ecg → login → pmr`
|
||||||
|
|
||||||
|
## What Needs to Change
|
||||||
|
|
||||||
|
### 1. Boot Sequence — Clean Up for Easy Config
|
||||||
|
|
||||||
|
**Problem:** Boot text lines are hardcoded as HTML strings with inline Tailwind classes. Adding/removing/reordering lines requires editing raw HTML. The `dangerouslySetInnerHTML` approach is fragile.
|
||||||
|
|
||||||
|
**Fix:** Refactor to a clean config-driven structure:
|
||||||
|
```typescript
|
||||||
|
// Example config structure — easy to customize
|
||||||
|
const BOOT_CONFIG = {
|
||||||
|
header: { text: 'CLINICAL TERMINAL v3.2.1', style: 'bright' },
|
||||||
|
lines: [
|
||||||
|
{ type: 'status', text: 'Initialising pharmacist profile...' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ type: 'field', label: 'SYSTEM', value: 'NHS Norfolk & Waveney ICB' },
|
||||||
|
{ type: 'field', label: 'USER', value: 'Andy Charlwood' },
|
||||||
|
{ type: 'field', label: 'ROLE', value: 'Deputy Head of Population Health & Data Analysis' },
|
||||||
|
{ type: 'field', label: 'LOCATION', value: 'Norwich, UK' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ type: 'status', text: 'Loading modules...' },
|
||||||
|
{ type: 'module', name: 'pharmacist_core.sys' },
|
||||||
|
{ type: 'module', name: 'population_health.mod' },
|
||||||
|
{ type: 'module', name: 'data_analytics.eng' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ type: 'ready', text: 'READY — Rendering CV..' },
|
||||||
|
],
|
||||||
|
timing: { lineDelay: 220, holdAfterComplete: 400, fadeOutDuration: 800 },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Each line type maps to a React component (not raw HTML)
|
||||||
|
- Colors remain: bright green `#00ff41`, dim green `#3a6b45`, cyan labels `#00e5ff`
|
||||||
|
- Staggered reveal timing stays the same (220ms per line)
|
||||||
|
- Font: Fira Code (this is the terminal phase, NOT the PMR — Fira Code is correct here)
|
||||||
|
|
||||||
|
### 2. Cursor → Dot Transition
|
||||||
|
|
||||||
|
**Problem:** The boot sequence ends with a blinking green block cursor (`.animate-blink`). The ECG animation starts with a glowing dot that appears at the far left of the screen. There's a visual disconnect — the cursor and dot don't connect.
|
||||||
|
|
||||||
|
**Fix:** The blinking cursor at the end of boot should smoothly transition INTO the ECG's glowing trace dot:
|
||||||
|
- At end of boot, capture the cursor's screen position (x, y)
|
||||||
|
- Pass this position to ECGAnimation via props
|
||||||
|
- ECGAnimation starts with its glowing dot AT the cursor position
|
||||||
|
- The cursor stops blinking and morphs: block cursor → circular glow (scale down width, increase glow, ~300ms)
|
||||||
|
- The dot then begins moving rightward, drawing the flatline/heartbeat trace behind it
|
||||||
|
- This means the ECG trace starts at the cursor position, NOT the far left edge
|
||||||
|
|
||||||
|
### 3. ECG Start Position
|
||||||
|
|
||||||
|
**Problem:** Currently the ECG trace starts at x=0 (far left of viewport). The cursor ends somewhere in the middle-left of the screen. This means the dot "teleports" from cursor position to the left edge.
|
||||||
|
|
||||||
|
**Fix:** The ECG animation should:
|
||||||
|
- Accept a `startPosition: { x: number, y: number }` prop from the boot sequence
|
||||||
|
- Begin the trace from that position
|
||||||
|
- The first section of trace is a flat line from the cursor position rightward
|
||||||
|
- Heartbeats begin after the first flat gap (same timing as now, just offset)
|
||||||
|
- The viewport scroll logic already handles the "head" position — just shift the world-space origin
|
||||||
|
|
||||||
|
### 4. Text Reveal — Mask Technique
|
||||||
|
|
||||||
|
**Problem:** The current ECGAnimation.tsx reveals letters by stroking them with progressive alpha (`letterProgress > 0.3` → fade in). This looks like letters fading in, not like the ECG line IS the letter shape.
|
||||||
|
|
||||||
|
**Reference:** `ECGCombined.tsx` (Remotion version at project root) uses a superior technique:
|
||||||
|
- The actual text characters are pre-rendered on the canvas (stroke-only, no fill)
|
||||||
|
- A wipe mask follows the ECG trace head, revealing the text underneath
|
||||||
|
- The ECG trace line IS the path that forms each letter shape (via the `getYAtX` function which returns letter Y values when in text region)
|
||||||
|
- Connector lines between letters sit at the baseline
|
||||||
|
- The neon glow filter applies to both the trace and revealed text
|
||||||
|
|
||||||
|
**What to adopt from ECGCombined.tsx:**
|
||||||
|
- The mask-based text reveal approach (the trace unveils pre-rendered text)
|
||||||
|
- The connector lines between letters at baseline
|
||||||
|
- The neon glow SVG filters (or canvas equivalents)
|
||||||
|
- The text mask brush that follows the trace head
|
||||||
|
|
||||||
|
**What to KEEP from current ECGAnimation.tsx:**
|
||||||
|
- The character spacing (current `LETTER_W`, `LETTER_G`, `SPACE_W` values — preferred over ECGCombined.tsx spacing)
|
||||||
|
- The heartbeat waveform shape (`generateHeartbeatPoints`)
|
||||||
|
- The beat timing and amplitude escalation (0.3 → 0.55 → 0.85 → 1.0)
|
||||||
|
- The canvas rendering approach (not SVG — canvas is correct for this performance-sensitive animation)
|
||||||
|
- The viewport scrolling/camera logic
|
||||||
|
- The flatline draw phase
|
||||||
|
- The scanline and vignette effects
|
||||||
|
- The background color transition to `#1E293B`
|
||||||
|
|
||||||
|
### 5. Text Rendering
|
||||||
|
|
||||||
|
- The name is still "ANDREW CHARLWOOD"
|
||||||
|
- Letters are stroke-only (no fill) in neon green `#00ff41`
|
||||||
|
- Each letter shape is defined by the `ECG_LETTERS` point arrays (keep these)
|
||||||
|
- The trace line passes through each letter's shape points, making the ECG waveform form recognizable letter shapes
|
||||||
|
- Between letters, the trace returns to baseline with short connector segments
|
||||||
|
- Neon glow effect on both trace and revealed text
|
||||||
|
|
||||||
|
## Transition to Login
|
||||||
|
|
||||||
|
After the text is fully revealed and the flatline extends to the right edge, the flow continues as described in `ref-transition-login.md`:
|
||||||
|
1. Name holds with glow (300ms)
|
||||||
|
2. Glow fades, flatline extends right
|
||||||
|
3. Canvas fades to black (200ms)
|
||||||
|
4. Background transitions to dark blue-gray `#1E293B` (200ms)
|
||||||
|
5. Login card materializes
|
||||||
|
|
||||||
|
## prefers-reduced-motion
|
||||||
|
|
||||||
|
With reduced motion enabled:
|
||||||
|
- Boot text appears instantly (no stagger)
|
||||||
|
- Cursor appears immediately
|
||||||
|
- ECG animation is skipped entirely — transition straight from boot to login
|
||||||
|
- Or: show the final frame (name fully revealed, flatline) as a static image for 1 second, then proceed to login
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Boot text renders correctly with all lines
|
||||||
|
- [ ] Blinking cursor visible at end of boot
|
||||||
|
- [ ] Cursor smoothly transitions to ECG dot (no teleport)
|
||||||
|
- [ ] ECG trace starts from cursor position
|
||||||
|
- [ ] Heartbeats render with increasing amplitude
|
||||||
|
- [ ] Name letters revealed via mask technique (not alpha fade)
|
||||||
|
- [ ] Connector lines between letters
|
||||||
|
- [ ] Neon glow on trace and text
|
||||||
|
- [ ] Flatline extends to right edge after name
|
||||||
|
- [ ] Background transitions to `#1E293B`
|
||||||
|
- [ ] Scanlines and vignette present
|
||||||
|
- [ ] Reduced motion: instant/static
|
||||||
|
- [ ] Mobile: scales correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Guidance (from /frontend-design)
|
||||||
|
|
||||||
|
> Pre-baked design direction. Do NOT invoke `/frontend-design` at runtime — this section contains the output.
|
||||||
|
|
||||||
|
### Aesthetic Direction: Authentic Clinical Terminal → Medical Monitor Realism
|
||||||
|
|
||||||
|
This isn't "medical-themed" design — this IS medical equipment interfaces. Two distinct phases:
|
||||||
|
1. **Boot Terminal**: Authentic 1990s clinical system boot sequence (think legacy pharmacy dispensing systems, hospital terminal logins). CRT monitor aesthetic with phosphor green, scanlines, slight text glow.
|
||||||
|
2. **ECG Monitor**: Hospital bedside cardiac monitor realism. The heartbeat isn't decorative — it's a functioning waveform that becomes letterforms through technical precision.
|
||||||
|
|
||||||
|
**Visual Signature**: The cursor-to-dot morphing transition. Most animations have discrete phases; this creates continuous material transformation — the blinking terminal cursor literally becomes the ECG trace point. It's the moment where "loading system" becomes "reading vital signs."
|
||||||
|
|
||||||
|
### Boot Sequence — Type-Safe Config
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type BootLineType = 'header' | 'status' | 'separator' | 'field' | 'module' | 'ready'
|
||||||
|
|
||||||
|
interface BootLine {
|
||||||
|
type: BootLineType
|
||||||
|
text: string
|
||||||
|
label?: string
|
||||||
|
value?: string
|
||||||
|
style?: 'bright' | 'dim' | 'cyan'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BootConfig {
|
||||||
|
header: string
|
||||||
|
lines: BootLine[]
|
||||||
|
timing: {
|
||||||
|
lineDelay: number
|
||||||
|
cursorBlinkInterval: number
|
||||||
|
holdAfterComplete: number
|
||||||
|
fadeOutDuration: number
|
||||||
|
}
|
||||||
|
colors: {
|
||||||
|
bright: string
|
||||||
|
dim: string
|
||||||
|
cyan: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Component architecture:
|
||||||
|
- `<BootLine>` — renders individual line types with appropriate styling
|
||||||
|
- `<BootCursor>` — separate component for cursor with ref exposure for position capture
|
||||||
|
- Config-driven rendering replaces hardcoded HTML
|
||||||
|
|
||||||
|
Example config:
|
||||||
|
```typescript
|
||||||
|
const BOOT_CONFIG: BootConfig = {
|
||||||
|
header: 'CLINICAL TERMINAL v3.2.1',
|
||||||
|
lines: [
|
||||||
|
{ type: 'status', text: 'Initialising pharmacist profile...', style: 'dim' },
|
||||||
|
{ type: 'separator', text: '---', style: 'dim' },
|
||||||
|
{ type: 'field', label: 'SYSTEM', value: 'NHS Norfolk & Waveney ICB', style: 'cyan' },
|
||||||
|
{ type: 'field', label: 'USER', value: 'Andy Charlwood', style: 'bright' },
|
||||||
|
// ... etc
|
||||||
|
],
|
||||||
|
timing: { lineDelay: 220, cursorBlinkInterval: 530, holdAfterComplete: 400, fadeOutDuration: 800 },
|
||||||
|
colors: { bright: '#00ff41', dim: '#3a6b45', cyan: '#00e5ff' }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example BootLine component:
|
||||||
|
```typescript
|
||||||
|
function BootLine({ line }: { line: BootLine }) {
|
||||||
|
const colors = BOOT_CONFIG.colors
|
||||||
|
const color = line.style ? colors[line.style] : colors.dim
|
||||||
|
|
||||||
|
if (line.type === 'field') {
|
||||||
|
return (
|
||||||
|
<div className="font-mono text-sm leading-relaxed">
|
||||||
|
<span style={{ color: colors.cyan }}>{line.label?.padEnd(9)}</span>
|
||||||
|
<span style={{ color }}>{line.value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.type === 'module') {
|
||||||
|
return (
|
||||||
|
<div className="font-mono text-sm leading-relaxed">
|
||||||
|
<span className="font-bold" style={{ color: colors.bright }}>[OK]</span>
|
||||||
|
{' '}
|
||||||
|
<span style={{ color: colors.dim }}>{line.text}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... other types
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cursor → Dot Transition — Implementation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [cursorPosition, setCursorPosition] = useState<{x: number, y: number} | null>(null)
|
||||||
|
const cursorRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cursorRef.current) {
|
||||||
|
const rect = cursorRef.current.getBoundingClientRect()
|
||||||
|
setCursorPosition({
|
||||||
|
x: rect.left + rect.width / 2,
|
||||||
|
y: rect.top + rect.height / 2
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [/* trigger when boot completes */])
|
||||||
|
|
||||||
|
// Pass to ECG:
|
||||||
|
<ECGAnimation startPosition={cursorPosition} onComplete={...} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Morph animation: width 8px→0px, height 16px→6px, border-radius 0→50% (300ms ease-out). Simultaneously fade in ECG dot at same position. After morph complete, begin trace movement.
|
||||||
|
|
||||||
|
### Canvas Mask Reveal — Implementation
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Pre-render text to offscreen canvas
|
||||||
|
const textCanvas = document.createElement('canvas')
|
||||||
|
const textCtx = textCanvas.getContext('2d')
|
||||||
|
textCtx.strokeStyle = lineColor
|
||||||
|
textCtx.lineWidth = 1.5
|
||||||
|
textCtx.font = `bold ${fontSize}px Arial`
|
||||||
|
textLayout.forEach(item => {
|
||||||
|
textCtx.strokeText(item.char, item.centerX, baselineY)
|
||||||
|
})
|
||||||
|
|
||||||
|
// During animation loop:
|
||||||
|
ctx.save()
|
||||||
|
|
||||||
|
// Create clipping path following trace head
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.rect(0, 0, headSX + 20, vh) // reveal up to head position + lead
|
||||||
|
ctx.clip()
|
||||||
|
|
||||||
|
// Draw pre-rendered text through clip
|
||||||
|
ctx.drawImage(textCanvas, -viewOff, 0)
|
||||||
|
|
||||||
|
ctx.restore()
|
||||||
|
|
||||||
|
// Feathered leading edge:
|
||||||
|
const gradient = ctx.createLinearGradient(headSX - 30, 0, headSX, 0)
|
||||||
|
gradient.addColorStop(0, 'rgba(0,255,65,0)')
|
||||||
|
gradient.addColorStop(1, 'rgba(0,255,65,1)')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connector Lines Between Letters
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const connectors: {startX: number, endX: number}[] = []
|
||||||
|
for (let i = 0; i < textLayout.length - 1; i++) {
|
||||||
|
const curr = textLayout[i]
|
||||||
|
const next = textLayout[i + 1]
|
||||||
|
const endInset = CONNECTOR_INSETS[curr.char] || 0
|
||||||
|
const startInset = CONNECTOR_INSETS[next.char] || 0
|
||||||
|
connectors.push({
|
||||||
|
startX: curr.endX - endInset,
|
||||||
|
endX: next.startX + startInset
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// During draw:
|
||||||
|
connectors.forEach(conn => {
|
||||||
|
if (headWX > conn.startX) {
|
||||||
|
const connectorEndX = Math.min(conn.endX, headWX)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(conn.startX - viewOff, baselineY)
|
||||||
|
ctx.lineTo(connectorEndX - viewOff, baselineY)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Visual Enhancement Details
|
||||||
|
|
||||||
|
**CRT Scanlines** (boot phase):
|
||||||
|
```css
|
||||||
|
.boot-scanlines {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
rgba(0, 0, 0, 0.15) 0px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 2px,
|
||||||
|
rgba(0, 0, 0, 0.15) 3px
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
animation: scanline-drift 8s linear infinite;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Phosphor Glow** (terminal text):
|
||||||
|
```css
|
||||||
|
.terminal-text {
|
||||||
|
text-shadow:
|
||||||
|
0 0 4px currentColor,
|
||||||
|
0 0 8px currentColor,
|
||||||
|
0 0 12px rgba(0, 255, 65, 0.3);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**ECG Neon Glow** (canvas — multi-layer):
|
||||||
|
- Primary trace: 2px solid line
|
||||||
|
- Glow layer 1: 6px @ 25% opacity + shadowBlur 14px
|
||||||
|
- Glow layer 2: Inner 3px core for sharpness
|
||||||
|
- Text: Same multi-layer glow approach
|
||||||
|
|
||||||
|
**Background Transition** (smooth RGB interpolation):
|
||||||
|
```javascript
|
||||||
|
const bgProgress = (elapsed - flatlineStartTime) / BG_TRANSITION
|
||||||
|
ctx.fillStyle = `rgb(
|
||||||
|
${Math.round(0 + (30 * bgProgress))},
|
||||||
|
${Math.round(0 + (41 * bgProgress))},
|
||||||
|
${Math.round(0 + (59 * bgProgress))}
|
||||||
|
)` // black → #1E293B
|
||||||
|
ctx.fillRect(0, 0, vw, vh)
|
||||||
|
```
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Reference: Consultations View (= Experience)
|
||||||
|
|
||||||
|
> Extracted from goal.md — Consultations View section. Each role is a "consultation entry" in a reverse-chronological journal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This is the core content view and the most detailed section. Each role is a "consultation entry" in a reverse-chronological journal.
|
||||||
|
|
||||||
|
## Journal List Layout
|
||||||
|
|
||||||
|
Entries are stacked vertically, most recent at top. Each entry has a collapsed state and an expanded state.
|
||||||
|
|
||||||
|
### Collapsed Entry
|
||||||
|
|
||||||
|
```
|
||||||
|
+------------------------------------------------------------------+
|
||||||
|
| (green dot) 14 May 2025 | NHS Norfolk & Waveney ICB |
|
||||||
|
| Interim Head, Population Health & Data Analysis |
|
||||||
|
| Key: 14.6M efficiency programme identified and delivered |
|
||||||
|
| [v Expand] |
|
||||||
|
+------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
- Date in Geist Mono, 13px, gray-500 (left-aligned)
|
||||||
|
- Organization in Inter 400, 13px, NHS blue
|
||||||
|
- Role title in Inter 600, 15px, gray-900
|
||||||
|
- Key coded entry: a single-line summary of the most notable achievement, prefixed with "Key:" in Inter 500, gray-500
|
||||||
|
- Expand chevron button (right-aligned)
|
||||||
|
- Status dot: green for current roles, gray for historical
|
||||||
|
|
||||||
|
### Expanded Entry (click to expand)
|
||||||
|
|
||||||
|
```
|
||||||
|
+------------------------------------------------------------------+
|
||||||
|
| (green dot) 14 May 2025 | NHS Norfolk & Waveney ICB [^ Close] |
|
||||||
|
| Interim Head, Population Health & Data Analysis |
|
||||||
|
| Duration: May 2025 - Nov 2025 |
|
||||||
|
| |
|
||||||
|
| HISTORY |
|
||||||
|
| Returned to substantive Deputy Head role following |
|
||||||
|
| commencement of ICB-wide organisational consultation. |
|
||||||
|
| Led strategic delivery of population health initiatives |
|
||||||
|
| and data-driven medicines optimisation across Norfolk & |
|
||||||
|
| Waveney ICS, reporting to Associate Director of Pharmacy. |
|
||||||
|
| |
|
||||||
|
| EXAMINATION |
|
||||||
|
| - Identified 14.6M efficiency programme through |
|
||||||
|
| comprehensive data analysis |
|
||||||
|
| - Built Python-based switching algorithm: 14,000 patients |
|
||||||
|
| identified, 2.6M annual savings |
|
||||||
|
| - Automated incentive scheme analysis: 50% reduction |
|
||||||
|
| in targeted prescribing within 2 months |
|
||||||
|
| |
|
||||||
|
| PLAN |
|
||||||
|
| - Achieved over-target performance by October 2025 |
|
||||||
|
| - 2M on target for delivery this financial year |
|
||||||
|
| - Presented to CMO bimonthly with evidence-based |
|
||||||
|
| recommendations |
|
||||||
|
| - Led transformation to patient-level SQL analytics |
|
||||||
|
| |
|
||||||
|
| CODED ENTRIES |
|
||||||
|
| [EFF001] Efficiency programme: 14.6M identified |
|
||||||
|
| [ALG001] Algorithm: 14,000 patients, 2.6M savings |
|
||||||
|
| [AUT001] Automation: 50% prescribing reduction in 2mo |
|
||||||
|
| [SQL001] Data transformation: practice->patient level |
|
||||||
|
+------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## The History / Examination / Plan Structure
|
||||||
|
|
||||||
|
This is a direct mapping from the clinical consultation format (SOAP notes) to career content:
|
||||||
|
|
||||||
|
| Clinical Term | CV Mapping | What Goes Here |
|
||||||
|
|--------------|------------|----------------|
|
||||||
|
| **History** | Context / Background | Why this role existed, what situation Andy walked into, reporting lines |
|
||||||
|
| **Examination** | Analysis / Findings | What Andy discovered, built, or analyzed — the technical and analytical work |
|
||||||
|
| **Plan** | Outcomes / Delivery | What was achieved, what impact was measured, what's ongoing |
|
||||||
|
|
||||||
|
Section headers ("HISTORY", "EXAMINATION", "PLAN") are styled in Inter 600, 12px, uppercase, letter-spacing 0.05em, gray-400 — exactly like the section dividers in a clinical consultation record.
|
||||||
|
|
||||||
|
## Coded Entries
|
||||||
|
|
||||||
|
At the bottom of each expanded consultation, "coded entries" appear — short-form tagged achievements with bracket codes. These mimic SNOMED CT / Read codes used in clinical systems. The codes are fictional but consistent (EFF = efficiency, ALG = algorithm, AUT = automation, SQL = data, etc.). Styled in Geist Mono, 12px, gray-500, with the code in brackets and the description after.
|
||||||
|
|
||||||
|
## Color Coding by Employer
|
||||||
|
|
||||||
|
Each consultation entry has a subtle left border (3px) indicating the employer:
|
||||||
|
- NHS Norfolk & Waveney ICB: NHS blue (`#005EB8`)
|
||||||
|
- Tesco PLC: Teal (`#00897B`)
|
||||||
|
|
||||||
|
This visual grouping helps the user quickly scan which organization each entry belongs to, without reading the text.
|
||||||
|
|
||||||
|
## Full Consultation Journal (all roles)
|
||||||
|
|
||||||
|
| Date | Organization | Role | Key Coded Entry |
|
||||||
|
|------|-------------|------|-----------------|
|
||||||
|
| May 2025 | NHS N&W ICB | Interim Head, Pop. Health & Data Analysis | [EFF001] 14.6M efficiency programme |
|
||||||
|
| Jul 2024 | NHS N&W ICB | Deputy Head, Pop. Health & Data Analysis | [BUD001] 220M budget management |
|
||||||
|
| May 2022 | NHS N&W ICB | High-Cost Drugs & Interface Pharmacist | [AUT002] Blueteq automation: 70% reduction |
|
||||||
|
| Nov 2017 | Tesco PLC | Pharmacy Manager | [INN001] Asthma screening: ~1M national revenue |
|
||||||
|
| Aug 2016 | Tesco PLC | Duty Pharmacy Manager | [REG001] GPhC registration commenced |
|
||||||
|
|
||||||
|
## Animation Behavior
|
||||||
|
|
||||||
|
- **Expand/collapse:** Height animation, 200ms, ease-out. No opacity fade — the content simply grows/shrinks.
|
||||||
|
- Only one consultation can be expanded at a time. Expanding a new entry collapses the previous one.
|
||||||
|
- The expand chevron rotates 180 degrees (pointing up when expanded).
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Reference: Visual Design System
|
||||||
|
|
||||||
|
> Extracted from goal.md — Visual System section. This is the SINGLE SOURCE OF TRUTH for colors, typography, spacing, borders, and motion throughout the Clinical Record PMR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color Palette
|
||||||
|
|
||||||
|
This design is **light-mode only**. Clinical record systems operate in light mode — high ambient lighting in consulting rooms demands white backgrounds and dark text. A dark mode would break the metaphor.
|
||||||
|
|
||||||
|
**Backgrounds:**
|
||||||
|
- Main content area: `#F5F7FA` (cool light gray — the content background of EMIS/SystmOne)
|
||||||
|
- Card/panel surfaces: `#FFFFFF` (white)
|
||||||
|
- Sidebar: `#1E293B` (dark blue-gray — EMIS-style dark navigation panel)
|
||||||
|
- Patient banner: `#334155` (lighter blue-gray with white text)
|
||||||
|
- Login screen background: `#1E293B` (same as sidebar — institutional dark blue-gray)
|
||||||
|
|
||||||
|
**Text:**
|
||||||
|
- Primary text: `#111827` (gray-900 — near-black for maximum readability)
|
||||||
|
- Secondary text: `#6B7280` (gray-500)
|
||||||
|
- On dark surfaces: `#FFFFFF` (white) and `#94A3B8` (slate-400 for secondary)
|
||||||
|
|
||||||
|
**Accent and status colors:**
|
||||||
|
- NHS blue: `#005EB8` — primary interactive color. Used for buttons, active nav states, links, column headers. This is the actual NHS brand blue and will be instantly recognized.
|
||||||
|
- Green: `#22C55E` — active/resolved/current states. "Active" status dots, resolved problems, current role indicators.
|
||||||
|
- Amber: `#F59E0B` — alerts, in-progress items, notable achievements. The clinical alert banner uses this as its background.
|
||||||
|
- Red: `#EF4444` — urgent/critical markers. Used sparingly — only for genuinely important items (e.g., a "priority" flag on the referral form).
|
||||||
|
- Gray: `#6B7280` — inactive/historical items. Past roles that are no longer current, historical "medications."
|
||||||
|
|
||||||
|
**Traffic light system (used throughout):**
|
||||||
|
- Green circle: Active / Resolved / Current
|
||||||
|
- Amber circle: In progress / Alert / Notable
|
||||||
|
- Red circle: Urgent / Critical (rare)
|
||||||
|
- Gray circle: Inactive / Historical
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
Clinical systems use system fonts — Inter or Segoe UI for general text, monospace for coded entries and data values. No decorative fonts, no variable tracking. Functional typography optimized for scanning dense tables.
|
||||||
|
|
||||||
|
- **Patient banner name:** Inter 600, 20px (not huge — clinical systems don't emphasize the patient name with large type)
|
||||||
|
- **Patient banner details:** Inter 400, 14px
|
||||||
|
- **Sidebar navigation labels:** Inter 500, 14px, white
|
||||||
|
- **Section headings (within main area):** Inter 600, 18px
|
||||||
|
- **Consultation entry titles:** Inter 600, 16px
|
||||||
|
- **Body text / descriptions:** Inter 400, 14px, line-height 1.6
|
||||||
|
- **Table headers:** Inter 600, 13px, uppercase, letter-spacing 0.03em, gray-500
|
||||||
|
- **Table data cells:** Inter 400, 14px
|
||||||
|
- **Coded entries / data values:** Geist Mono 400, 13px
|
||||||
|
- **Clinical codes (SNOMED-style):** Geist Mono 400, 12px, gray-400
|
||||||
|
- **Timestamps:** Geist Mono 400, 12px
|
||||||
|
- **Alert banner text:** Inter 500, 14px
|
||||||
|
|
||||||
|
## Spacing and Layout
|
||||||
|
|
||||||
|
- **Sidebar width:** 220px (fixed, desktop). Collapses to 56px (icon-only) on tablet.
|
||||||
|
- **Patient banner height:** 80px (full), 48px (condensed/sticky)
|
||||||
|
- **Main content max-width:** No max-width — clinical systems fill available space. Content flows within the area between sidebar and viewport edge.
|
||||||
|
- **Main content padding:** 24px
|
||||||
|
- **Card padding:** 16px (clinical systems are more compact than marketing sites)
|
||||||
|
- **Border radius:** 4px for cards and inputs (clinical systems use minimal rounding — 4px, not 12px or 16px)
|
||||||
|
- **Table row height:** 40px
|
||||||
|
- **Section spacing:** 24px between content blocks
|
||||||
|
- **Base unit:** 4px — tighter spacing than typical, reflecting clinical system density
|
||||||
|
|
||||||
|
## Borders and Surfaces
|
||||||
|
|
||||||
|
Borders are the dominant visual structuring element. Clinical systems do not rely on shadows or negative space — they use explicit borders to delineate every element.
|
||||||
|
|
||||||
|
- **All cards:** `1px solid #E5E7EB` (gray-200) border, `4px` border-radius, no shadow (or at most `0 1px 2px rgba(0,0,0,0.03)`)
|
||||||
|
- **Table cells:** `1px solid #E5E7EB` borders (all sides)
|
||||||
|
- **Sidebar border:** `1px solid #334155` (subtle right border in a slightly lighter shade)
|
||||||
|
- **Patient banner border:** `1px solid #475569` bottom border
|
||||||
|
- **Input fields:** `1px solid #D1D5DB` border, `4px` radius, `#FFFFFF` background, `8px 12px` padding
|
||||||
|
- **Active/selected rows:** `#EFF6FF` background (very subtle blue tint) — this is how EMIS highlights the selected row
|
||||||
|
|
||||||
|
## Motion
|
||||||
|
|
||||||
|
Clinical systems are fast and functional. Animations are minimal and purposeful — no spring physics, no bouncy transitions. Everything is immediate or uses simple ease-out.
|
||||||
|
|
||||||
|
- **Navigation switches:** Instant content swap. No crossfade, no slide. When you click a sidebar item, the main content area replaces immediately — just like clicking a tab in EMIS.
|
||||||
|
- **Consultation expand/collapse:** Height animation, 200ms, `ease-out`. No opacity fade — the content simply grows/shrinks.
|
||||||
|
- **Alert banner entrance:** Slide down from top, 250ms, with a subtle spring overshoot (this is the one exception — alerts are meant to demand attention).
|
||||||
|
- **Alert acknowledge:** The alert shrinks in height to zero (200ms) with a small green checkmark that flashes briefly.
|
||||||
|
- **Hover states:** Background-color transitions, 100ms. No transforms, no lifts. Just color.
|
||||||
|
- **Login typing:** Character-by-character reveal using `setInterval` (30ms per character for username, 20ms per dot for password).
|
||||||
|
- **Patient banner scroll condensation:** Smooth height transition (200ms) from full (80px) to condensed (48px) as user scrolls past the first 100px of content.
|
||||||
|
- **`prefers-reduced-motion`:** Typing animation completes instantly (full text appears), alert slides are replaced with fade-in, expand/collapse is instant.
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# Reference: Interactions, Responsive Design, and Accessibility
|
||||||
|
|
||||||
|
> Extracted from goal.md — Interactions, Responsive Strategy, and Accessibility sections.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Interactions and Micro-interactions
|
||||||
|
|
||||||
|
### Sidebar Navigation
|
||||||
|
- Clicking a sidebar item instantly swaps the main content area. No crossfade, no transition — just an immediate swap. This matches clinical system behavior exactly: navigation is instant.
|
||||||
|
- The active sidebar item updates its left border (3px, NHS blue) and background tint simultaneously, with no animation (instant state change).
|
||||||
|
|
||||||
|
### Consultation Expand / Collapse
|
||||||
|
- Clicking a consultation entry toggles between collapsed and expanded states.
|
||||||
|
- The expand animation: height grows from 0 to content height over 200ms, ease-out. Content opacity transitions from 0 to 1 over the same duration.
|
||||||
|
- Only one consultation can be expanded at a time. Expanding a new entry collapses the previous one.
|
||||||
|
- The expand chevron rotates 180 degrees (pointing up when expanded).
|
||||||
|
|
||||||
|
### Medication Row Hover
|
||||||
|
- Hovering a medication table row changes its background to `#EFF6FF` (subtle blue tint).
|
||||||
|
- No transform, no elevation change. Just color.
|
||||||
|
|
||||||
|
### Table Column Sorting
|
||||||
|
- Clicking a table column header sorts by that column. An arrow indicator (up/down) appears in the header.
|
||||||
|
- Clicking the same header again reverses sort direction.
|
||||||
|
- Sorting is instant (no animation on row reordering).
|
||||||
|
|
||||||
|
### Patient Banner Scroll Condensation
|
||||||
|
- As the user scrolls past 100px of content, the patient banner smoothly transitions from full (80px) to condensed (48px) over 200ms.
|
||||||
|
- The condensed banner shows only: name, NHS number, status dot, and action buttons.
|
||||||
|
- Scrolling back to top restores the full banner.
|
||||||
|
- Uses `position: sticky` with an `IntersectionObserver` to trigger the condensation.
|
||||||
|
|
||||||
|
### Alert Acknowledge
|
||||||
|
- Clicking "Acknowledge" on a clinical alert:
|
||||||
|
1. The warning icon cross-fades to a green checkmark (200ms)
|
||||||
|
2. After a 200ms hold, the alert's height animates to 0 (200ms, ease-out)
|
||||||
|
3. Content below shifts upward to fill the space (same 200ms timing)
|
||||||
|
|
||||||
|
### Search
|
||||||
|
- A search input in the sidebar header ("Search record...") provides fuzzy matching across all PMR sections.
|
||||||
|
- Typing shows a dropdown of results grouped by section (Consultations, Medications, Problems, etc.).
|
||||||
|
- Each result shows the section icon, the matching text, and a relevance indicator.
|
||||||
|
- Pressing Enter or clicking a result navigates to that section with the matching item highlighted/expanded.
|
||||||
|
- Implementation: fuse.js for fuzzy search across a pre-built index of all content.
|
||||||
|
|
||||||
|
### Context Menus
|
||||||
|
- Right-clicking (desktop) or long-pressing (mobile) on certain elements reveals a context menu:
|
||||||
|
- On a consultation entry: "Expand", "Copy to clipboard", "View coded entries"
|
||||||
|
- On a medication row: "View prescribing history", "Copy to clipboard"
|
||||||
|
- On a problem entry: "View linked consultations", "Copy to clipboard"
|
||||||
|
- Context menus styled: white background, `1px solid #E5E7EB` border, 4px radius, `box-shadow: 0 4px 12px rgba(0,0,0,0.1)`. Items in Inter 400, 14px, 36px row height.
|
||||||
|
|
||||||
|
### Login Screen Typing
|
||||||
|
- The username types character-by-character (30ms per character).
|
||||||
|
- The password dots appear faster (20ms per dot).
|
||||||
|
- A blinking cursor appears in the active field (530ms blink interval).
|
||||||
|
- The "Log In" button shows a brief active/pressed state before the interface materializes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responsive Strategy
|
||||||
|
|
||||||
|
### Desktop (>1024px)
|
||||||
|
The full PMR experience. This is the design's primary target — clinical systems are desktop applications.
|
||||||
|
- Sidebar: 220px, always visible, dark blue-gray
|
||||||
|
- Patient banner: full width, 80px height, condensing to 48px on scroll
|
||||||
|
- Main content: fills remaining width (no max-width constraint)
|
||||||
|
- Tables: full column display, alternating row colors, sort controls
|
||||||
|
- Consultations: full History/Examination/Plan expanded view
|
||||||
|
- Search: integrated in sidebar header
|
||||||
|
|
||||||
|
### Tablet (768-1024px)
|
||||||
|
Sidebar collapses to icon-only mode (56px width). Hovering or tapping an icon shows the label as a tooltip.
|
||||||
|
- Patient banner: condensed to single-line format always (no full/condensed toggle)
|
||||||
|
- Main content: nearly full width
|
||||||
|
- Tables: may horizontally scroll if columns exceed available width
|
||||||
|
- Context menus: triggered by long-press instead of right-click
|
||||||
|
|
||||||
|
### Mobile (<768px)
|
||||||
|
The sidebar becomes a **bottom navigation bar** with 7 icon buttons.
|
||||||
|
|
||||||
|
**Bottom nav layout:**
|
||||||
|
```
|
||||||
|
[Summary] [Consult] [Meds] [Problems] [Invest] [Docs] [Refer]
|
||||||
|
```
|
||||||
|
|
||||||
|
Each icon from Lucide, 20px, with the active item highlighted in NHS blue with a label below. Height: 56px with safe area padding.
|
||||||
|
|
||||||
|
**Patient banner on mobile:** Minimal top bar: `CHARLWOOD, A (Mr) | 2211810 | (dot)` — action buttons collapse into "..." overflow menu.
|
||||||
|
|
||||||
|
**Content adaptations:**
|
||||||
|
- Tables switch to card layout: each row becomes a small card with fields stacked vertically
|
||||||
|
- Consultation entries: tap-to-expand pattern with larger tap targets (48px minimum height)
|
||||||
|
- Medications: table becomes stacked card list
|
||||||
|
- Referral form: full-width inputs, generous touch targets
|
||||||
|
- Search: moves to top of each view as a search bar
|
||||||
|
|
||||||
|
**Back navigation:** Each view has a back arrow returning to Summary.
|
||||||
|
|
||||||
|
### Breakpoint Summary
|
||||||
|
|
||||||
|
| Element | Desktop (>1024) | Tablet (768-1024) | Mobile (<768) |
|
||||||
|
|---------|-----------------|-------------------|---------------|
|
||||||
|
| Sidebar | 220px, full labels | 56px, icons only | Bottom nav bar |
|
||||||
|
| Patient banner | 80px full / 48px sticky | 48px always | Minimal top bar |
|
||||||
|
| Tables | Full columns, horizontal | Scroll if needed | Card layout (stacked) |
|
||||||
|
| Search | Sidebar header | Sidebar header | Top of each view |
|
||||||
|
| Context menus | Right-click | Long-press | Long-press |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
### Semantic HTML
|
||||||
|
- Sidebar: `<nav role="navigation" aria-label="Clinical record navigation">` with `<ul>` and `<li>` items. Active item uses `aria-current="page"`.
|
||||||
|
- Patient banner: `<header role="banner">` containing patient demographics.
|
||||||
|
- Main content area: `<main>` element with `aria-label` matching the current view name.
|
||||||
|
- Tables: Proper `<table>`, `<thead>`, `<th>`, `<tbody>`, `<tr>`, `<td>` markup. Column headers use `scope="col"`.
|
||||||
|
- Consultation entries: `<article>` elements with `<button>` for expand/collapse, `aria-expanded` attribute.
|
||||||
|
|
||||||
|
### Keyboard Navigation
|
||||||
|
- `Tab` moves between: sidebar items, patient banner buttons, main content interactive elements
|
||||||
|
- `ArrowUp` / `ArrowDown` within the sidebar moves between navigation items (roving tabindex)
|
||||||
|
- `Enter` / `Space` on sidebar items activates that view
|
||||||
|
- `Enter` / `Space` on consultation entries toggles expand/collapse
|
||||||
|
- `Alt+1` through `Alt+7` directly activates sidebar items
|
||||||
|
- `Escape` closes expanded items, context menus, and search dropdown
|
||||||
|
- Search input focusable with `/` key
|
||||||
|
|
||||||
|
### Screen Reader Experience
|
||||||
|
1. After login, announces: "Patient Record for Charlwood, Andrew. Summary view."
|
||||||
|
2. Clinical alert announced via `role="alert"`: full alert text
|
||||||
|
3. Tables announced with column headers
|
||||||
|
4. Expandable items announce expanded/collapsed state
|
||||||
|
5. Breadcrumb uses `<nav aria-label="Breadcrumb">`
|
||||||
|
|
||||||
|
### Alert Accessibility
|
||||||
|
- Uses `role="alert"` and `aria-live="assertive"`
|
||||||
|
- Acknowledge button: `aria-label="Acknowledge clinical alert"`
|
||||||
|
- Removal is smooth (element removes from accessibility tree)
|
||||||
|
|
||||||
|
### Focus Management
|
||||||
|
- After login: focus moves to first sidebar item (Summary)
|
||||||
|
- After navigating to new view: focus moves to first heading in main content
|
||||||
|
- After expanding consultation: focus moves to HISTORY heading
|
||||||
|
- After closing context menu: focus returns to trigger element
|
||||||
|
- After acknowledging alert: focus moves to main content first interactive element
|
||||||
|
|
||||||
|
### Color and Contrast
|
||||||
|
- All text meets WCAG 2.1 AA contrast requirements
|
||||||
|
- Traffic lights never sole communicator — always with text labels
|
||||||
|
- NHS blue on white: ~7.3:1 contrast ratio
|
||||||
|
- Amber alert text on amber bg: ~5.8:1 contrast ratio
|
||||||
|
|
||||||
|
### Motion Preferences
|
||||||
|
When `prefers-reduced-motion: reduce`:
|
||||||
|
- Login typing completes instantly
|
||||||
|
- Alert appears without slide
|
||||||
|
- Expand/collapse is instant
|
||||||
|
- Banner condensation is instant
|
||||||
|
- Hover background-color changes remain
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Reference: Investigations View (= Projects) + Documents View (= Education)
|
||||||
|
|
||||||
|
> Extracted from goal.md — Investigations and Documents sections. Two simpler views that share the expandable-row pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Investigations View (= Projects)
|
||||||
|
|
||||||
|
Projects presented as diagnostic investigations — tests that were ordered, performed, and returned results.
|
||||||
|
|
||||||
|
### Investigation List
|
||||||
|
|
||||||
|
```
|
||||||
|
+--[ Investigation Results ]----------------------------------------------+
|
||||||
|
| Test Name | Requested | Status | Result |
|
||||||
|
|------------------------------+-----------+----------+-------------------|
|
||||||
|
| PharMetrics Interactive | 2024 | Complete | Live (green) |
|
||||||
|
| Platform | | | |
|
||||||
|
| Patient Switching Algorithm | 2025 | Complete | 14,000 pts found |
|
||||||
|
| Blueteq Generator | 2023 | Complete | 70% reduction |
|
||||||
|
| CD Monitoring System | 2024 | Complete | Population-scale |
|
||||||
|
| Sankey Chart Analysis Tool | 2023 | Complete | Pathway audit |
|
||||||
|
| Patient Pathway Analysis | 2024 | Ongoing | In development |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### Status Badges
|
||||||
|
|
||||||
|
Styled like laboratory result status indicators:
|
||||||
|
- **Complete** (green dot): Investigation finished, results available
|
||||||
|
- **Ongoing** (amber dot): Investigation still in progress
|
||||||
|
- **Live** (pulsing green dot): Results are actively being used (for PharMetrics, which is a live URL)
|
||||||
|
|
||||||
|
### Expanded Investigation View
|
||||||
|
|
||||||
|
Clicking an investigation row reveals a detailed "results panel" below the row:
|
||||||
|
|
||||||
|
```
|
||||||
|
PharMetrics Interactive Platform
|
||||||
|
|-- Date Requested: 2024
|
||||||
|
|-- Date Reported: 2024
|
||||||
|
|-- Status: Complete - Live at medicines.charlwood.xyz
|
||||||
|
|-- Requesting Clinician: A. Charlwood
|
||||||
|
|-- Methodology:
|
||||||
|
| Real-time medicines expenditure dashboard providing
|
||||||
|
| actionable analytics for NHS decision-makers. Built with
|
||||||
|
| Power BI and SQL, tracking expenditure across the 220M
|
||||||
|
| prescribing budget.
|
||||||
|
|-- Results:
|
||||||
|
| - Real-time tracking of medicines expenditure
|
||||||
|
| - Actionable analytics for budget holders
|
||||||
|
| - Self-serve model for wider team
|
||||||
|
|-- Tech Stack: Power BI, SQL, DAX
|
||||||
|
|-- [View Results ->] (external link to medicines.charlwood.xyz)
|
||||||
|
```
|
||||||
|
|
||||||
|
The expanded view uses a tree-like indented structure (with box-drawing characters in monospace) to present the investigation report. This mirrors how lab results and imaging reports appear in clinical systems — structured, indented, with labelled fields.
|
||||||
|
|
||||||
|
### "View Results" Link
|
||||||
|
|
||||||
|
For PharMetrics (the only project with a live URL), a "View Results" button appears styled as an NHS blue action button. For internal projects, this button is absent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documents View (= Education & Certifications)
|
||||||
|
|
||||||
|
Education and certifications presented as attached documents in the patient record.
|
||||||
|
|
||||||
|
### Document List
|
||||||
|
|
||||||
|
```
|
||||||
|
+--[ Attached Documents ]-------------------------------------------------+
|
||||||
|
| Type | Document | Date | Source |
|
||||||
|
|----------------+----------------------------------+---------+------------|
|
||||||
|
| Certificate | MPharm (Hons) 2:1 | 2015 | UEA |
|
||||||
|
| Registration | GPhC Pharmacist Registration | 2016 | GPhC |
|
||||||
|
| Certificate | Mary Seacole Programme (78%) | 2018 | NHS LA |
|
||||||
|
| Results | A-Levels: Maths A*, Chem B, | 2011 | Highworth |
|
||||||
|
| | Politics C | | Grammar |
|
||||||
|
| Research | Drug Delivery & Cocrystals | 2015 | UEA |
|
||||||
|
| | (75.1% Distinction) | | |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### Document Type Icons
|
||||||
|
|
||||||
|
Small document icons from Lucide:
|
||||||
|
- `FileText` for certificates
|
||||||
|
- `Award` for registrations
|
||||||
|
- `GraduationCap` for academic results
|
||||||
|
- `FlaskConical` for research
|
||||||
|
|
||||||
|
### Expanded Document Preview
|
||||||
|
|
||||||
|
```
|
||||||
|
MPharm (Hons) 2:1 - University of East Anglia
|
||||||
|
|-- Type: Academic Qualification
|
||||||
|
|-- Date Awarded: 2015
|
||||||
|
|-- Institution: University of East Anglia, Norwich
|
||||||
|
|-- Classification: Upper Second-Class Honours (2:1)
|
||||||
|
|-- Duration: 2011 - 2015 (4 years)
|
||||||
|
|-- Research: Drug delivery and cocrystals
|
||||||
|
| Grade: 75.1% (Distinction)
|
||||||
|
|-- Notes: MPharm is a 4-year integrated Master's degree
|
||||||
|
required for pharmacist registration in the UK.
|
||||||
|
```
|
||||||
|
|
||||||
|
The preview panel uses the same tree-indented structure as the Investigations expanded view, maintaining visual consistency across the PMR interface.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Reference: Medications View (= Skills)
|
||||||
|
|
||||||
|
> Extracted from goal.md — Medications View section. Skills presented as an active medications list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Skills presented as an active medications list — the format every pharmacist and GP reads daily.
|
||||||
|
|
||||||
|
## Full Table Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
+--[ Active Medications ]-------------------------------------------------+
|
||||||
|
| Drug Name | Dose | Frequency | Start | Status |
|
||||||
|
|--------------------+-------+------------+----------+-------------------|
|
||||||
|
| Python | 90% | Daily | 2017 | Active (green) |
|
||||||
|
| SQL | 88% | Daily | 2017 | Active (green) |
|
||||||
|
| Power BI | 92% | Daily | 2019 | Active (green) |
|
||||||
|
| Data Analysis | 95% | Daily | 2016 | Active (green) |
|
||||||
|
| JavaScript / TS | 70% | Weekly | 2020 | Active (green) |
|
||||||
|
| Dashboard Dev | 88% | Weekly | 2019 | Active (green) |
|
||||||
|
| Algorithm Design | 82% | Weekly | 2022 | Active (green) |
|
||||||
|
| Data Pipelines | 80% | Weekly | 2022 | Active (green) |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
|
||||||
|
+--[ Clinical Medications ]-----------------------------------------------+
|
||||||
|
| Drug Name | Dose | Frequency | Start | Status |
|
||||||
|
|-------------------------+-------+------------+--------+----------------|
|
||||||
|
| Medicines Optimisation | 95% | Daily | 2016 | Active (green) |
|
||||||
|
| Pop. Health Analytics | 90% | Daily | 2022 | Active (green) |
|
||||||
|
| NICE TA Implementation | 85% | Weekly | 2022 | Active (green) |
|
||||||
|
| Health Economics | 80% | Monthly | 2023 | Active (green) |
|
||||||
|
| Clinical Pathways | 82% | Weekly | 2022 | Active (green) |
|
||||||
|
| CD Assurance | 88% | Weekly | 2024 | Active (green) |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
|
||||||
|
+--[ PRN (As Required) ]--------------------------------------------------+
|
||||||
|
| Drug Name | Dose | Frequency | Start | Status |
|
||||||
|
|-------------------------+-------+------------+--------+----------------|
|
||||||
|
| Budget Management | 90% | As needed | 2024 | Active (green) |
|
||||||
|
| Stakeholder Engagement | 88% | As needed | 2022 | Active (green) |
|
||||||
|
| Pharma Negotiation | 85% | As needed | 2024 | Active (green) |
|
||||||
|
| Team Development | 82% | As needed | 2017 | Active (green) |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## Column Definitions
|
||||||
|
|
||||||
|
| Column | PMR Meaning | CV Mapping |
|
||||||
|
|--------|------------|------------|
|
||||||
|
| Drug Name | Medication name | Skill name |
|
||||||
|
| Dose | Dosage strength | Proficiency percentage |
|
||||||
|
| Frequency | How often taken | How often the skill is used (Daily / Weekly / Monthly / As needed) |
|
||||||
|
| Start | Date prescribed | Year Andy started using this skill (approximate) |
|
||||||
|
| Status | Active / Stopped | Active (green dot) for current skills, Historical (gray dot) for deprecated skills |
|
||||||
|
|
||||||
|
## Medication Categories (tabs within the view)
|
||||||
|
|
||||||
|
Skills are grouped into three "medication types," mimicking how clinical systems separate regular, acute, and PRN medications:
|
||||||
|
|
||||||
|
- **Active Medications** = Technical skills (the "regular medications" — taken daily, core to function)
|
||||||
|
- **Clinical Medications** = Healthcare domain skills (the specialist prescriptions)
|
||||||
|
- **PRN (As Required)** = Strategic & leadership skills (used situationally, not daily)
|
||||||
|
|
||||||
|
## Table Styling
|
||||||
|
|
||||||
|
- Table headers: Inter 600, 13px, uppercase, gray-400, `#F9FAFB` background
|
||||||
|
- Table rows: alternating `#FFFFFF` / `#F9FAFB` backgrounds
|
||||||
|
- Row height: 40px
|
||||||
|
- All borders: `1px solid #E5E7EB`
|
||||||
|
- Hover state: row background changes to `#EFF6FF` (subtle blue tint)
|
||||||
|
- Status dots: 6px circles, inline with status text
|
||||||
|
|
||||||
|
## Interaction — Prescribing History
|
||||||
|
|
||||||
|
Clicking any medication/skill row expands it downward to show a "prescribing history" — a mini-timeline of how the skill developed:
|
||||||
|
|
||||||
|
```
|
||||||
|
Python | 90% | Daily | 2017 | Active (green)
|
||||||
|
|-- Prescribing History:
|
||||||
|
2017 Started: Self-taught for data analysis automation
|
||||||
|
2019 Increased: Dashboard development, data pipeline work
|
||||||
|
2022 Specialist use: Blueteq automation, Sankey analysis tools
|
||||||
|
2024 Advanced: Switching algorithm (14,000 patients), CD monitoring
|
||||||
|
2025 Current: Population-level analytics, incentive scheme automation
|
||||||
|
```
|
||||||
|
|
||||||
|
The history entries are styled in Geist Mono, 12px, with year markers as bold anchors and descriptions in regular weight. This "prescribing history" shows skill progression in a format that clinicians understand intuitively.
|
||||||
|
|
||||||
|
## Sortable Columns
|
||||||
|
|
||||||
|
Table columns are sortable by clicking the header. Clicking "Dose" sorts by proficiency descending. Clicking "Start" sorts chronologically. A small sort indicator arrow appears in the active sort column header. Default sort: by category grouping.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Reference: Problems View (= Achievements / Challenges)
|
||||||
|
|
||||||
|
> Extracted from goal.md — Problems View section. Career achievements framed as clinical problems that were identified, treated, and resolved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The "Problems" list in a clinical record tracks diagnoses — conditions that were identified, treated, and either resolved or require ongoing management. This maps perfectly to career achievements: challenges that Andy identified and resolved.
|
||||||
|
|
||||||
|
## Two Sections: Active Problems and Resolved Problems
|
||||||
|
|
||||||
|
### Active Problems (current / ongoing)
|
||||||
|
|
||||||
|
```
|
||||||
|
+--[ Active Problems ]----------------------------------------------------+
|
||||||
|
| Status | Code | Problem | Since |
|
||||||
|
|--------+-----------+--------------------------------------+------------|
|
||||||
|
| AMB | [MGT001] | 220M prescribing budget oversight | Jul 2024 |
|
||||||
|
| GRN | [TRN001] | Patient-level SQL transformation | 2025 |
|
||||||
|
| AMB | [LEA001] | Team data literacy programme | Jul 2024 |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resolved Problems (past achievements)
|
||||||
|
|
||||||
|
```
|
||||||
|
+--[ Resolved Problems ]--------------------------------------------------+
|
||||||
|
| Status | Code | Problem | Resolved | Outcome |
|
||||||
|
|--------+-----------+--------------------------------+-----------+------------------------------------------|
|
||||||
|
| GRN | [EFF001] | Manual prescribing analysis | Oct 2025 | Python algorithm: 14,000 pts, 2.6M/yr |
|
||||||
|
| | | inefficiency | | |
|
||||||
|
| GRN | [EFF002] | 14.6M efficiency target | Oct 2025 | Over-target performance achieved |
|
||||||
|
| GRN | [AUT001] | Blueteq form creation backlog | 2023 | 70% reduction, 200hrs saved |
|
||||||
|
| GRN | [INN001] | Asthma screening scalability | 2019 | National rollout: ~300 branches, ~1M |
|
||||||
|
| GRN | [AUT002] | Incentive scheme manual calc. | 2025 | Automated: 50% Rx reduction in 2 months |
|
||||||
|
| GRN | [DAT001] | HCD spend tracking gaps | 2023 | Blueteq-secondary care data integration |
|
||||||
|
| GRN | [VIS001] | Patient pathway opacity | 2023 | Sankey chart analysis tool |
|
||||||
|
| GRN | [MON001] | Population opioid exposure | 2024 | CD monitoring system: OME tracking |
|
||||||
|
| | | monitoring | | |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## Column Definitions
|
||||||
|
|
||||||
|
| Column | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| Status | Traffic light: Green (resolved), Amber (in progress / active), Red (urgent — unused, reserved) |
|
||||||
|
| Code | SNOMED-style reference code. Fictional but internally consistent. Formatted in Geist Mono. |
|
||||||
|
| Problem | The challenge or opportunity Andy identified |
|
||||||
|
| Resolved | Date or year the problem was resolved |
|
||||||
|
| Outcome | Brief description of the resolution and its measurable impact |
|
||||||
|
|
||||||
|
## Expandable Rows
|
||||||
|
|
||||||
|
Each problem row can be expanded to show a full narrative: what the problem was, how Andy approached it, what tools/methods were used, and the quantified outcome. The expanded state also shows "linked consultations" — clicking a link navigates to the relevant entry in Consultations view.
|
||||||
|
|
||||||
|
## Traffic Light Status Indicators
|
||||||
|
|
||||||
|
Traffic lights are 8px circles with the status colors (green, amber, red, gray). They appear inline before the code column. This is exactly how clinical systems indicate problem severity/status — it's an immediately scannable visual language.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Reference: Referrals View (= Contact)
|
||||||
|
|
||||||
|
> Extracted from goal.md — Referrals View section. Contact information presented as a clinical referral form.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Contact information presented as a clinical referral form — the mechanism for "referring" a patient (Andy) to another service.
|
||||||
|
|
||||||
|
## Referral Form Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
+--[ New Referral ]-------------------------------------------------------+
|
||||||
|
| |
|
||||||
|
| Referring to: CHARLWOOD, Andrew (Mr) |
|
||||||
|
| NHS Number: 221 181 0 |
|
||||||
|
| |
|
||||||
|
| Priority: ( ) Urgent (*) Routine ( ) Two-Week Wait |
|
||||||
|
| |
|
||||||
|
| Referrer Name: [________________________] |
|
||||||
|
| Referrer Email: [________________________] |
|
||||||
|
| Referrer Org: [________________________] (optional) |
|
||||||
|
| |
|
||||||
|
| Reason for Referral: |
|
||||||
|
| [ ] |
|
||||||
|
| [ ] |
|
||||||
|
| [ ] |
|
||||||
|
| |
|
||||||
|
| Contact Method: ( ) Email ( ) Phone ( ) LinkedIn |
|
||||||
|
| |
|
||||||
|
| [ Cancel ] [ Send Referral ] |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## Priority Toggle (tongue-in-cheek)
|
||||||
|
|
||||||
|
Three radio options styled like clinical referral priorities:
|
||||||
|
- **Urgent**: Red label, red dot. Selectable but the tooltip reads "All enquiries are welcome, urgent or not."
|
||||||
|
- **Routine**: Blue label, blue dot. Default selected.
|
||||||
|
- **Two-Week Wait**: Amber label. Tooltip: "NHS cancer referral pathway - this isn't that, but the spirit of promptness applies."
|
||||||
|
|
||||||
|
This is the design's main moment of humor. The priority options are visually authentic to clinical referral forms, and the tongue-in-cheek tooltips reward exploration without undermining the professional tone.
|
||||||
|
|
||||||
|
## Form Fields
|
||||||
|
|
||||||
|
Standard clinical form inputs: `1px solid #D1D5DB` border, `4px` radius, `8px 12px` padding. Labels in Inter 500, 13px, gray-600, positioned above inputs. Focus state: border changes to NHS blue, subtle blue glow (`box-shadow: 0 0 0 3px rgba(0, 94, 184, 0.15)`).
|
||||||
|
|
||||||
|
## Submit Button
|
||||||
|
|
||||||
|
"Send Referral" in NHS blue (`#005EB8`), white text, full width of the right half of the form. On hover: darkens to `#004494`. On click: brief loading state (spinner icon), then a success message:
|
||||||
|
|
||||||
|
```
|
||||||
|
Checkmark Referral sent successfully
|
||||||
|
Reference: REF-2026-0210-001
|
||||||
|
Expected response time: 24-48 hours
|
||||||
|
```
|
||||||
|
|
||||||
|
The reference number is generated from the current date. The success state mimics the confirmation screen shown after submitting a clinical referral in EMIS.
|
||||||
|
|
||||||
|
## Alternative Contact Methods (below the form)
|
||||||
|
|
||||||
|
```
|
||||||
|
+--[ Direct Contact ]-----------------------------------------------------+
|
||||||
|
| Email: andy@charlwood.xyz [Send Email ->] |
|
||||||
|
| Phone: 07795553088 [Call ->] |
|
||||||
|
| LinkedIn: linkedin.com/in/andycharlwood [View Profile ->] |
|
||||||
|
| Location: Norwich, UK |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
Styled as a simple key-value table, same format as the Patient Demographics card in Summary view.
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Reference: Summary View + Clinical Alert
|
||||||
|
|
||||||
|
> Extracted from goal.md — Summary View and Clinical Alert sections. This is the landing view after login.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary View
|
||||||
|
|
||||||
|
The landing view after login. This mimics the "Patient Summary" screen — the first screen a clinician sees when opening a patient record, showing the most important information at a glance.
|
||||||
|
|
||||||
|
**Layout:** A grid of summary cards arranged in a 2-column layout on desktop, single column on mobile. Each card has a header bar with the card title in Inter 600, 14px, uppercase, on a `#F9FAFB` background with `1px solid #E5E7EB` bottom border.
|
||||||
|
|
||||||
|
### Card 1: Patient Demographics (spans full width)
|
||||||
|
|
||||||
|
```
|
||||||
|
+--[ Patient Demographics ]------------------------------------------+
|
||||||
|
| Name: Andrew Charlwood Status: Active (dot) |
|
||||||
|
| DOB: 14 February 1993 Location: Norwich, UK |
|
||||||
|
| Registration: GPhC 2211810 Since: August 2016 |
|
||||||
|
| Qualification: MPharm (Hons) 2:1 University: UEA, 2015 |
|
||||||
|
+---------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
A two-column key-value table. Labels in Inter 500, 13px, gray-500. Values in Inter 400, 14px, gray-900. Labels right-aligned, values left-aligned — mimicking clinical system demographics layout.
|
||||||
|
|
||||||
|
### Card 2: Active Problems (left column)
|
||||||
|
|
||||||
|
```
|
||||||
|
+--[ Active Problems ]-----------------------------------------------+
|
||||||
|
| (green dot) Deputy Head, Pop. Health & Data Analysis Jul 2024-Present |
|
||||||
|
| NHS Norfolk & Waveney ICB |
|
||||||
|
| (green dot) 220M prescribing budget management Ongoing |
|
||||||
|
| (amber dot) Patient-level SQL analytics transformation In progress |
|
||||||
|
+---------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
A list with green dots for active/current items, amber dots for in-progress items. Each entry has a title in Inter 500, 14px, and a date range or status in Geist Mono, 12px, right-aligned. Click an entry to navigate to the corresponding Consultation.
|
||||||
|
|
||||||
|
### Card 3: Current Medications — Quick View (right column)
|
||||||
|
|
||||||
|
```
|
||||||
|
+--[ Current Medications (Quick View) ]-------------------------------+
|
||||||
|
| Python | 90% | Daily | Active (green dot) |
|
||||||
|
| SQL | 88% | Daily | Active (green dot) |
|
||||||
|
| Power BI | 92% | Daily | Active (green dot) |
|
||||||
|
| Data Analysis | 95% | Daily | Active (green dot) |
|
||||||
|
| JS / TypeScript | 70% | Weekly | Active (green dot) |
|
||||||
|
| [View Full List ->] |
|
||||||
|
+---------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
A compact 4-column table showing the top 5 skills. "View Full List" links to the Medications view. Table headers are uppercase, 12px, gray-400. Table rows alternate between `#FFFFFF` and `#F9FAFB` backgrounds.
|
||||||
|
|
||||||
|
### Card 4: Last Consultation (spans full width)
|
||||||
|
|
||||||
|
```
|
||||||
|
+--[ Last Consultation ]----------------------------------------------+
|
||||||
|
| Date: May 2025 Clinician: A. Charlwood Location: NHS N&W ICB |
|
||||||
|
| |
|
||||||
|
| Interim Head, Population Health & Data Analysis |
|
||||||
|
| Led strategic delivery of population health initiatives and |
|
||||||
|
| data-driven medicines optimisation across Norfolk & Waveney ICS... |
|
||||||
|
| [View Full Record ->] |
|
||||||
|
+---------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
A preview of the most recent role, truncated to 2-3 lines. "View Full Record" navigates to Consultations with that entry expanded.
|
||||||
|
|
||||||
|
### Card 5: Alerts (full width, positioned above all other cards)
|
||||||
|
|
||||||
|
This is the Clinical Alert — see below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Clinical Alert (Signature Interaction)
|
||||||
|
|
||||||
|
When the user first loads the Summary view (immediately after the login transition), a clinical alert banner slides down from beneath the patient banner.
|
||||||
|
|
||||||
|
### Alert Styling
|
||||||
|
|
||||||
|
```
|
||||||
|
+--[ WARNING CLINICAL ALERT ]------------------------------------------+
|
||||||
|
| WARNING ALERT: This patient has identified 14.6M in prescribing |
|
||||||
|
| efficiency savings across Norfolk & Waveney ICS. |
|
||||||
|
| [Acknowledge]|
|
||||||
|
+----------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
- Background: amber (`#FEF3C7` — amber-100, light amber)
|
||||||
|
- Left border: 4px solid `#F59E0B` (amber-500)
|
||||||
|
- Warning icon: `AlertTriangle` from Lucide, amber-600
|
||||||
|
- Text: Inter 500, 14px, `#92400E` (amber-800)
|
||||||
|
- "Acknowledge" button: small outlined button, amber border and text
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
1. The alert slides down from beneath the patient banner with a spring animation (250ms, slight overshoot) after the PMR interface finishes materializing.
|
||||||
|
2. It pushes the Summary content downward, so it's impossible to miss.
|
||||||
|
3. Clicking "Acknowledge" triggers a brief animation: a green checkmark replaces the warning icon (200ms), then the alert collapses upward (200ms, ease-out) and is gone.
|
||||||
|
4. The dismiss state is stored in React state (session-only) — refreshing the page shows the alert again.
|
||||||
|
|
||||||
|
### Why This Works
|
||||||
|
|
||||||
|
Clinical alerts are the mechanism that clinical systems use to put critical information in front of clinicians before they do anything else. They are the highest-priority information in the system. By framing Andy's most impressive metric ("14.6M") as a clinical alert, it gets the same treatment — it's the first thing the user reads, it demands acknowledgment, and its format gives the number institutional weight. This is not a boast in a paragraph; it's a system-generated alert based on data. The framing makes the achievement feel objective.
|
||||||
|
|
||||||
|
### Second Alert (on Consultations view)
|
||||||
|
|
||||||
|
When the user first navigates to Consultations, a secondary alert appears:
|
||||||
|
|
||||||
|
```
|
||||||
|
WARNING NOTE: Patient has developed a Python-based switching algorithm
|
||||||
|
identifying 14,000 patients for cost-effective medication alternatives.
|
||||||
|
2.6M annual savings potential. Review recommended.
|
||||||
|
```
|
||||||
|
|
||||||
|
This second alert reinforces the key technical achievement in clinical language. It appears only once (on first navigation to Consultations) and is dismissible with the same "Acknowledge" interaction.
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# Reference: ECG Transition + Login Sequence
|
||||||
|
|
||||||
|
> Extracted from goal.md — ECG Transition section. This covers the flatline exit from the ECG animation and the immersive login sequence that bridges into the PMR interface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Starting Point
|
||||||
|
|
||||||
|
"ANDREW CHARLWOOD" is on screen in neon green (`#00ff41`) on black. The heartbeat trace is complete. The name is fully formed and glowing.
|
||||||
|
|
||||||
|
## Phase 1: The Flatline (600ms)
|
||||||
|
|
||||||
|
The neon green name holds for a beat (300ms). Then the glow around the letters begins to fade. Simultaneously, from the right edge of the name, a flatline trace extends rightward — a perfectly horizontal green line drawn at the baseline, extending across the remaining viewport width over 300ms. The visual reads as a patient monitor flatline. This is deliberate: the "patient" (the animation phase) is ending. A new record is about to open.
|
||||||
|
|
||||||
|
The flatline has a subtle audio-visual implication without actual sound — the green line is steady and unbroken, the glow around the name letters reduces to zero. The entire canvas is now: a fading green name with a horizontal flatline extending to the right edge. All on black.
|
||||||
|
|
||||||
|
## Phase 2: Screen Clear (400ms)
|
||||||
|
|
||||||
|
The entire canvas fades to black over 200ms (the name and flatline dissolve into darkness). Then, from black, the background transitions to a dark blue-gray (`#1E293B`) over 200ms. This is the color of a clinical system login screen — the dark institutional background that every NHS worker recognizes from their Monday morning.
|
||||||
|
|
||||||
|
## Phase 3: Login Sequence (1200ms)
|
||||||
|
|
||||||
|
A login panel materializes center-screen: a white card (320px wide, 12px border-radius, subtle shadow) on the dark blue-gray background. The card contains:
|
||||||
|
|
||||||
|
- A small NHS-blue shield icon or generic clinical system logo at the top
|
||||||
|
- **Username field**: Empty text input with label "Username". After 200ms, a cursor appears and types `A.CHARLWOOD` character by character (30ms per character, ~350ms total). The typing uses Geist Mono / monospace font.
|
||||||
|
- **Password field**: After a 150ms pause, dots fill the password field in rapid succession (8 dots, 20ms each, ~160ms total).
|
||||||
|
- **"Log In" button**: NHS blue (`#005EB8`), full width. After another 150ms pause, the button receives a subtle pressed state (darkens slightly, 100ms) as if clicked.
|
||||||
|
|
||||||
|
The login card holds for 200ms in its "submitted" state, then...
|
||||||
|
|
||||||
|
## Phase 4: Interface Materialization (500ms)
|
||||||
|
|
||||||
|
The login card scales up slightly (103%) and fades out (200ms). As it fades, the full PMR interface fades in behind it:
|
||||||
|
|
||||||
|
1. **Patient banner** slides down from the top edge (200ms, ease-out)
|
||||||
|
2. **Sidebar** slides in from the left edge (250ms, ease-out, starting 50ms after the banner)
|
||||||
|
3. **Main content area** (Summary view) fades in (300ms, starting 100ms after sidebar begins)
|
||||||
|
4. **Clinical alert banner** slides down from beneath the patient banner (250ms, spring easing, starting 200ms after main content appears)
|
||||||
|
|
||||||
|
## Phase 5: Final State
|
||||||
|
|
||||||
|
The full PMR interface is visible: patient banner at top, dark sidebar on left, Summary view in the main content area, and the clinical alert banner demanding attention. The user is now "logged in" to Andy's career record.
|
||||||
|
|
||||||
|
**Total transition duration:** ~2.7 seconds
|
||||||
|
|
||||||
|
## Why This Works
|
||||||
|
|
||||||
|
The login sequence is the most immersive transition of all designs. Every NHS worker, every pharmacist, every GP has typed their credentials into a clinical system at 8am on a Monday. This transition puts them right there. It's specific, it's authentic, and it immediately establishes the metaphor: you are opening a patient record. The "patient" happens to be a career.
|
||||||
|
|
||||||
|
## Login Animation Implementation Notes
|
||||||
|
|
||||||
|
- Component mounts with dark blue-gray background
|
||||||
|
- Login card fades in (Framer Motion, 200ms)
|
||||||
|
- Username typing: `setInterval` adds one character per 30ms to a state string
|
||||||
|
- Password dots: `setInterval` adds one dot per 20ms
|
||||||
|
- Button press: state change triggers visual pressed state, then 200ms delay
|
||||||
|
- `onComplete` callback fires, parent component swaps to PMRInterface
|
||||||
|
- Typing respects `prefers-reduced-motion` — with reduced motion, full username appears instantly and login completes in ~500ms total
|
||||||
|
- **Font: Geist Mono** for username/password fields (NOT Fira Code)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Guidance (from /frontend-design)
|
||||||
|
|
||||||
|
> Pre-baked design direction. Do NOT invoke `/frontend-design` at runtime — this section contains the output.
|
||||||
|
|
||||||
|
### Aesthetic Direction: Institutional Utilitarian
|
||||||
|
|
||||||
|
This is not "exciting" design — it is the visual equivalent of fluorescent lights, laminate desks, and the smell of hand sanitiser at 07:58 on a Monday morning. The card must look like every single hospital login prompt a doctor has ever seen: clean white, unadorned, functional. No personality. The branding is the only concession to identity. The magic is not visual flair — it is the uncanny recognition of "oh, this is exactly what that looks like" combined with the satisfying typewriter rhythm of credentials appearing.
|
||||||
|
|
||||||
|
### Key Design Decisions
|
||||||
|
|
||||||
|
1. **Active field focus ring**: NHS-blue border (`1px solid #005EB8`) on the currently active field, inactive fields shift to `#FAFAFA` background. Mirrors real NHS login forms (Lorenzo, SystmOne, EMIS Web). Transition 150ms.
|
||||||
|
2. **Reduced shadow to spec**: Use exactly `0 1px 2px rgba(0,0,0,0.03)`. Card sits on dark background through border definition, not shadow depth — more faithful to real NHS software.
|
||||||
|
3. **Border**: Use `1px solid #E5E7EB` per design system (not `rgba(255,255,255,0.1)`).
|
||||||
|
4. **Timer cleanup**: Track every `setInterval` and `setTimeout` via refs, clear all on unmount.
|
||||||
|
5. **Consolidated active field state**: Single `activeField` state (`'username' | 'password' | null`) instead of separate booleans.
|
||||||
|
6. **Accessibility**: `role="status"` + `aria-label` on outer container. Cursor pipes `aria-hidden="true"`. Card entrance `scale: 0.98` (not 0).
|
||||||
|
7. **The Monday-morning feeling**: No gradients, no decorative elements, no loading spinners, no "Welcome back!" messaging. Just white rectangle on gray, shield icon, two fields, button. Typing speed deliberately mechanical.
|
||||||
|
|
||||||
|
### Implementation Pattern
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Shield } from 'lucide-react'
|
||||||
|
|
||||||
|
interface LoginScreenProps {
|
||||||
|
onComplete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key state
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [passwordDots, setPasswordDots] = useState(0)
|
||||||
|
const [showCursor, setShowCursor] = useState(true)
|
||||||
|
const [activeField, setActiveField] = useState<'username' | 'password' | null>('username')
|
||||||
|
const [buttonPressed, setButtonPressed] = useState(false)
|
||||||
|
const [isExiting, setIsExiting] = useState(false)
|
||||||
|
|
||||||
|
const fullUsername = 'A.CHARLWOOD'
|
||||||
|
const passwordLength = 8
|
||||||
|
```
|
||||||
|
|
||||||
|
Card structure:
|
||||||
|
```tsx
|
||||||
|
<motion.div
|
||||||
|
className="bg-white"
|
||||||
|
style={{
|
||||||
|
width: '320px',
|
||||||
|
padding: '32px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px solid #E5E7EB',
|
||||||
|
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.03)',
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={isExiting ? { scale: 1.03, opacity: 0 } : { scale: 1, opacity: 1 }}
|
||||||
|
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
Branding header:
|
||||||
|
```tsx
|
||||||
|
<div className="flex flex-col items-center" style={{ marginBottom: '28px' }}>
|
||||||
|
<div style={{
|
||||||
|
padding: '10px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
backgroundColor: 'rgba(0, 94, 184, 0.07)',
|
||||||
|
marginBottom: '10px',
|
||||||
|
}}>
|
||||||
|
<Shield size={26} style={{ color: '#005EB8' }} strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: "'Inter', system-ui, sans-serif",
|
||||||
|
fontSize: '13px', fontWeight: 600,
|
||||||
|
color: '#64748B', letterSpacing: '0.01em',
|
||||||
|
}}>CareerRecord PMR</span>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: "'Inter', system-ui, sans-serif",
|
||||||
|
fontSize: '11px', fontWeight: 400,
|
||||||
|
color: '#94A3B8', marginTop: '2px',
|
||||||
|
}}>Clinical Information System</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Input field pattern (username example):
|
||||||
|
```tsx
|
||||||
|
<div style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '9px 11px',
|
||||||
|
fontFamily: "'Geist Mono', 'Fira Code', monospace",
|
||||||
|
fontSize: '13px',
|
||||||
|
backgroundColor: activeField === 'username' ? '#FFFFFF' : '#FAFAFA',
|
||||||
|
border: activeField === 'username' ? '1px solid #005EB8' : '1px solid #E5E7EB',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#111827',
|
||||||
|
minHeight: '38px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<span>{username}</span>
|
||||||
|
{activeField === 'username' && (
|
||||||
|
<span style={{ opacity: showCursor ? 1 : 0, color: '#005EB8' }} aria-hidden="true">|</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Login button:
|
||||||
|
```tsx
|
||||||
|
<button style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 16px',
|
||||||
|
fontFamily: "'Inter', system-ui, sans-serif",
|
||||||
|
fontSize: '14px', fontWeight: 600,
|
||||||
|
color: '#FFFFFF',
|
||||||
|
backgroundColor: buttonPressed ? '#004494' : '#005EB8',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
}}>Log In</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Typing sequence (reduced motion branch):
|
||||||
|
```tsx
|
||||||
|
if (prefersReducedMotion) {
|
||||||
|
setUsername(fullUsername)
|
||||||
|
setPasswordDots(passwordLength)
|
||||||
|
setActiveField(null)
|
||||||
|
setTimeout(() => { setButtonPressed(true); setTimeout(triggerComplete, 100) }, 300)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Normal: username at 30ms/char, 150ms pause, password at 20ms/dot, 150ms pause, button press
|
||||||
|
```
|
||||||
|
|
||||||
|
Footer:
|
||||||
|
```tsx
|
||||||
|
<div style={{ marginTop: '22px', paddingTop: '18px', borderTop: '1px solid #E5E7EB' }}>
|
||||||
|
<p style={{
|
||||||
|
fontFamily: "'Inter', system-ui, sans-serif",
|
||||||
|
fontSize: '11px', color: '#94A3B8', textAlign: 'center',
|
||||||
|
}}>Secure clinical system login</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
<title>Andy Charlwood — MPharm | CV</title>
|
<title>Andy Charlwood — MPharm | CV</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Inter+Tight:wght@400;500;600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Inter+Tight:wght@400;500;600&family=Inter:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+15
-12
@@ -4,26 +4,29 @@ import { BootSequence } from './components/BootSequence'
|
|||||||
import { ECGAnimation } from './components/ECGAnimation'
|
import { ECGAnimation } from './components/ECGAnimation'
|
||||||
import { LoginScreen } from './components/LoginScreen'
|
import { LoginScreen } from './components/LoginScreen'
|
||||||
import { PMRInterface } from './components/PMRInterface'
|
import { PMRInterface } from './components/PMRInterface'
|
||||||
|
import { AccessibilityProvider } from './contexts/AccessibilityContext'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [phase, setPhase] = useState<Phase>('boot')
|
const [phase, setPhase] = useState<Phase>('boot')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<AccessibilityProvider>
|
||||||
{phase === 'boot' && (
|
<div className="min-h-screen bg-black">
|
||||||
<BootSequence onComplete={() => setPhase('ecg')} />
|
{phase === 'boot' && (
|
||||||
)}
|
<BootSequence onComplete={() => setPhase('ecg')} />
|
||||||
|
)}
|
||||||
|
|
||||||
{phase === 'ecg' && (
|
{phase === 'ecg' && (
|
||||||
<ECGAnimation onComplete={() => setPhase('login')} />
|
<ECGAnimation onComplete={() => setPhase('login')} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phase === 'login' && (
|
{phase === 'login' && (
|
||||||
<LoginScreen onComplete={() => setPhase('pmr')} />
|
<LoginScreen onComplete={() => setPhase('pmr')} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phase === 'pmr' && <PMRInterface />}
|
{phase === 'pmr' && <PMRInterface />}
|
||||||
</div>
|
</div>
|
||||||
|
</AccessibilityProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface BootLine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bootLines: BootLine[] = [
|
const bootLines: BootLine[] = [
|
||||||
|
|
||||||
{ html: '<span class="text-[#00ff41] font-bold">CLINICAL TERMINAL v3.2.1</span>', delay: 0 },
|
{ html: '<span class="text-[#00ff41] font-bold">CLINICAL TERMINAL v3.2.1</span>', delay: 0 },
|
||||||
{ html: '<span class="text-[#3a6b45]">Initialising pharmacist profile...</span>', delay: 220 },
|
{ html: '<span class="text-[#3a6b45]">Initialising pharmacist profile...</span>', delay: 220 },
|
||||||
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
|
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
|
||||||
@@ -23,24 +24,25 @@ const bootLines: BootLine[] = [
|
|||||||
{ html: '<span class="text-[#00ff41] font-bold">> READY — Rendering CV..<span class="ecg-seed-dot" id="ecg-seed-dot">.</span></span>', delay: 220 },
|
{ html: '<span class="text-[#00ff41] font-bold">> READY — Rendering CV..<span class="ecg-seed-dot" id="ecg-seed-dot">.</span></span>', delay: 220 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Precompute cumulative delays so the first render can use them
|
||||||
|
const bootLineDelays: number[] = (() => {
|
||||||
|
const delays: number[] = []
|
||||||
|
let total = 0
|
||||||
|
bootLines.forEach((line) => {
|
||||||
|
delays.push(total)
|
||||||
|
total += line.delay
|
||||||
|
})
|
||||||
|
return delays
|
||||||
|
})()
|
||||||
|
|
||||||
interface BootSequenceProps {
|
interface BootSequenceProps {
|
||||||
onComplete: () => void
|
onComplete: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BootSequence({ onComplete }: BootSequenceProps) {
|
export function BootSequence({ onComplete }: BootSequenceProps) {
|
||||||
const [isVisible, setIsVisible] = useState(true)
|
const [isVisible, setIsVisible] = useState(true)
|
||||||
const [lineDelays, setLineDelays] = useState<number[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const delays: number[] = []
|
const totalBootTime = bootLines.reduce((sum, l) => sum + l.delay, 0)
|
||||||
let totalDelay = 0
|
|
||||||
bootLines.forEach((line) => {
|
|
||||||
delays.push(totalDelay)
|
|
||||||
totalDelay += line.delay
|
|
||||||
})
|
|
||||||
setLineDelays(delays)
|
|
||||||
|
|
||||||
const totalBootTime = totalDelay
|
|
||||||
const fadeStartTime = totalBootTime + 400
|
const fadeStartTime = totalBootTime + 400
|
||||||
|
|
||||||
const fadeTimer = setTimeout(() => {
|
const fadeTimer = setTimeout(() => {
|
||||||
@@ -49,7 +51,7 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
|
|||||||
|
|
||||||
const completeTimer = setTimeout(() => {
|
const completeTimer = setTimeout(() => {
|
||||||
onComplete()
|
onComplete()
|
||||||
}, fadeStartTime + 800)
|
}, fadeStartTime+2000)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(fadeTimer)
|
clearTimeout(fadeTimer)
|
||||||
@@ -63,10 +65,10 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
|
|||||||
<motion.div
|
<motion.div
|
||||||
className="fixed inset-0 z-50 flex flex-col justify-center bg-black p-10 font-mono text-sm overflow-hidden"
|
className="fixed inset-0 z-50 flex flex-col justify-center bg-black p-10 font-mono text-sm overflow-hidden"
|
||||||
initial={{ opacity: 1 }}
|
initial={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 1 }}
|
||||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
transition={{ delay: 2, duration: 0.8, ease: 'easeOut' }}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-1 max-w-[640px]">
|
<div className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2">
|
||||||
{bootLines.map((line, index) => (
|
{bootLines.map((line, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={index}
|
key={index}
|
||||||
@@ -74,7 +76,7 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
|
|||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{
|
transition={{
|
||||||
delay: lineDelays[index] / 1000,
|
delay: (bootLineDelays[index] ?? 0) / 1000,
|
||||||
duration: 0.4,
|
duration: 0.4,
|
||||||
ease: 'easeOut',
|
ease: 'easeOut',
|
||||||
}}
|
}}
|
||||||
@@ -85,7 +87,7 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
|
|||||||
className="inline-block w-2 h-4 bg-[#00ff41] ml-1 animate-blink"
|
className="inline-block w-2 h-4 bg-[#00ff41] ml-1 animate-blink"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ delay: lineDelays[lineDelays.length - 1] / 1000 }}
|
transition={{ delay: 2 + (bootLineDelays[bootLineDelays.length + 1] ?? 0) / 1000 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { ViewId } from '../types/pmr'
|
import type { ViewId } from '../types/pmr'
|
||||||
|
import { useAccessibility } from '../contexts/AccessibilityContext'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
id: ViewId
|
id: ViewId
|
||||||
@@ -21,6 +22,7 @@ interface NavItem {
|
|||||||
interface ClinicalSidebarProps {
|
interface ClinicalSidebarProps {
|
||||||
activeView: ViewId
|
activeView: ViewId
|
||||||
onViewChange: (view: ViewId) => void
|
onViewChange: (view: ViewId) => void
|
||||||
|
isTablet?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
@@ -41,10 +43,56 @@ function getCurrentTime(): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClinicalSidebar({ activeView, onViewChange }: ClinicalSidebarProps) {
|
export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }: ClinicalSidebarProps) {
|
||||||
const [currentTime, setCurrentTime] = useState(getCurrentTime)
|
const [currentTime, setCurrentTime] = useState(getCurrentTime)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [isSearchFocused, setIsSearchFocused] = useState(false)
|
const [isSearchFocused, setIsSearchFocused] = useState(false)
|
||||||
|
const [focusedIndex, setFocusedIndex] = useState<number | null>(null)
|
||||||
|
const [hoveredItem, setHoveredItem] = useState<ViewId | null>(null)
|
||||||
|
const navButtonRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||||
|
const { focusAfterLoginRef } = useAccessibility()
|
||||||
|
|
||||||
|
const handleNavClick = useCallback(
|
||||||
|
(view: ViewId) => {
|
||||||
|
onViewChange(view)
|
||||||
|
window.location.hash = view
|
||||||
|
},
|
||||||
|
[onViewChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleNavKeyDown = useCallback((e: React.KeyboardEvent, index: number) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
if (index < navItems.length - 1) {
|
||||||
|
setFocusedIndex(index + 1)
|
||||||
|
navButtonRefs.current[index + 1]?.focus()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
if (index > 0) {
|
||||||
|
setFocusedIndex(index - 1)
|
||||||
|
navButtonRefs.current[index - 1]?.focus()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault()
|
||||||
|
handleNavClick(navItems[index].id)
|
||||||
|
break
|
||||||
|
case 'Home':
|
||||||
|
e.preventDefault()
|
||||||
|
setFocusedIndex(0)
|
||||||
|
navButtonRefs.current[0]?.focus()
|
||||||
|
break
|
||||||
|
case 'End':
|
||||||
|
e.preventDefault()
|
||||||
|
setFocusedIndex(navItems.length - 1)
|
||||||
|
navButtonRefs.current[navItems.length - 1]?.focus()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}, [handleNavClick])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@@ -88,13 +136,11 @@ export function ClinicalSidebar({ activeView, onViewChange }: ClinicalSidebarPro
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [onViewChange, isSearchFocused])
|
}, [onViewChange, isSearchFocused])
|
||||||
|
|
||||||
const handleNavClick = useCallback(
|
useEffect(() => {
|
||||||
(view: ViewId) => {
|
if (navButtonRefs.current[0]) {
|
||||||
onViewChange(view)
|
;(focusAfterLoginRef as React.MutableRefObject<HTMLButtonElement | null>).current = navButtonRefs.current[0]
|
||||||
window.location.hash = view
|
}
|
||||||
},
|
}, [focusAfterLoginRef])
|
||||||
[onViewChange]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
@@ -117,6 +163,69 @@ export function ClinicalSidebar({ activeView, onViewChange }: ClinicalSidebarPro
|
|||||||
)
|
)
|
||||||
}, [searchQuery])
|
}, [searchQuery])
|
||||||
|
|
||||||
|
if (isTablet) {
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
role="navigation"
|
||||||
|
aria-label="Clinical record navigation"
|
||||||
|
className="hidden md:flex lg:hidden flex-col w-14 h-screen sticky top-0 bg-pmr-sidebar text-white"
|
||||||
|
>
|
||||||
|
<div className="p-2 border-b border-white/10">
|
||||||
|
<div className="font-inter font-medium text-[10px] text-white/50 text-center leading-tight">
|
||||||
|
PMR
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 py-2 overflow-y-auto">
|
||||||
|
<ul role="menu" aria-label="Record sections">
|
||||||
|
{navItems.map((item, index) => (
|
||||||
|
<li key={item.id} role="none" className="relative">
|
||||||
|
{index === 1 && (
|
||||||
|
<div className="mx-2 my-1 border-t border-white/10" role="separator" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
ref={el => { navButtonRefs.current[index] = el }}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
tabIndex={focusedIndex === null ? (index === 0 ? 0 : -1) : (focusedIndex === index ? 0 : -1)}
|
||||||
|
aria-current={activeView === item.id ? 'page' : undefined}
|
||||||
|
aria-label={item.label}
|
||||||
|
onClick={() => handleNavClick(item.id)}
|
||||||
|
onKeyDown={e => handleNavKeyDown(e, index)}
|
||||||
|
onMouseEnter={() => setHoveredItem(item.id)}
|
||||||
|
onMouseLeave={() => setHoveredItem(null)}
|
||||||
|
className={`
|
||||||
|
w-full flex items-center justify-center h-11
|
||||||
|
transition-colors relative
|
||||||
|
${activeView === item.id
|
||||||
|
? 'text-white bg-white/12 border-l-[3px] border-pmr-nhsblue'
|
||||||
|
: 'text-white/70 hover:text-white hover:bg-white/8'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span className={activeView === item.id ? 'text-white' : 'text-white/60'}>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
{hoveredItem === item.id && (
|
||||||
|
<div className="absolute left-full ml-2 px-2 py-1 bg-gray-900 text-white text-xs rounded whitespace-nowrap z-50 font-inter">
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-2 border-t border-white/10">
|
||||||
|
<div className="font-inter text-[9px] text-slate-400 text-center leading-relaxed">
|
||||||
|
<div>A.C</div>
|
||||||
|
<div>{currentTime}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
role="navigation"
|
role="navigation"
|
||||||
@@ -179,17 +288,20 @@ export function ClinicalSidebar({ activeView, onViewChange }: ClinicalSidebarPro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 py-2 overflow-y-auto">
|
<nav className="flex-1 py-2 overflow-y-auto">
|
||||||
<ul role="list">
|
<ul role="menu" aria-label="Record sections">
|
||||||
{navItems.map((item, index) => (
|
{navItems.map((item, index) => (
|
||||||
<li key={item.id}>
|
<li key={item.id} role="none">
|
||||||
{index === 1 && (
|
{index === 1 && (
|
||||||
<div className="mx-3 my-1 border-t border-white/10" role="separator" />
|
<div className="mx-3 my-1 border-t border-white/10" role="separator" aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
ref={el => { navButtonRefs.current[index] = el }}
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
tabIndex={focusedIndex === null ? (index === 0 ? 0 : -1) : (focusedIndex === index ? 0 : -1)}
|
||||||
aria-current={activeView === item.id ? 'page' : undefined}
|
aria-current={activeView === item.id ? 'page' : undefined}
|
||||||
onClick={() => handleNavClick(item.id)}
|
onClick={() => handleNavClick(item.id)}
|
||||||
|
onKeyDown={e => handleNavKeyDown(e, index)}
|
||||||
className={`w-full flex items-center gap-3 h-11 px-4 text-left transition-colors ${
|
className={`w-full flex items-center gap-3 h-11 px-4 text-left transition-colors ${
|
||||||
activeView === item.id
|
activeView === item.id
|
||||||
? 'text-white bg-white/12 border-l-[3px] border-pmr-nhsblue font-semibold'
|
? 'text-white bg-white/12 border-l-[3px] border-pmr-nhsblue font-semibold'
|
||||||
|
|||||||
+120
-33
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
import { Shield } from 'lucide-react'
|
import { Shield } from 'lucide-react'
|
||||||
|
import { useAccessibility } from '../contexts/AccessibilityContext'
|
||||||
|
|
||||||
interface LoginScreenProps {
|
interface LoginScreenProps {
|
||||||
onComplete: () => void
|
onComplete: () => void
|
||||||
@@ -12,6 +14,8 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
|||||||
const [isTypingUsername, setIsTypingUsername] = useState(true)
|
const [isTypingUsername, setIsTypingUsername] = useState(true)
|
||||||
const [isTypingPassword, setIsTypingPassword] = useState(false)
|
const [isTypingPassword, setIsTypingPassword] = useState(false)
|
||||||
const [buttonPressed, setButtonPressed] = useState(false)
|
const [buttonPressed, setButtonPressed] = useState(false)
|
||||||
|
const [isExiting, setIsExiting] = useState(false)
|
||||||
|
const { requestFocusAfterLogin } = useAccessibility()
|
||||||
|
|
||||||
const fullUsername = 'A.CHARLWOOD'
|
const fullUsername = 'A.CHARLWOOD'
|
||||||
const passwordLength = 8
|
const passwordLength = 8
|
||||||
@@ -20,13 +24,23 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
|||||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
: false
|
: false
|
||||||
|
|
||||||
|
const triggerComplete = useCallback(() => {
|
||||||
|
setIsExiting(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
requestFocusAfterLogin()
|
||||||
|
onComplete()
|
||||||
|
}, prefersReducedMotion ? 0 : 200)
|
||||||
|
}, [onComplete, requestFocusAfterLogin, prefersReducedMotion])
|
||||||
|
|
||||||
const startLoginSequence = useCallback(() => {
|
const startLoginSequence = useCallback(() => {
|
||||||
if (prefersReducedMotion) {
|
if (prefersReducedMotion) {
|
||||||
setUsername(fullUsername)
|
setUsername(fullUsername)
|
||||||
setPasswordDots(passwordLength)
|
setPasswordDots(passwordLength)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setButtonPressed(true)
|
setButtonPressed(true)
|
||||||
setTimeout(onComplete, 200)
|
setTimeout(() => {
|
||||||
|
triggerComplete()
|
||||||
|
}, 100)
|
||||||
}, 300)
|
}, 300)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -55,14 +69,16 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setButtonPressed(true)
|
setButtonPressed(true)
|
||||||
setTimeout(onComplete, 200)
|
setTimeout(() => {
|
||||||
|
triggerComplete()
|
||||||
|
}, 100)
|
||||||
}, 150)
|
}, 150)
|
||||||
}
|
}
|
||||||
}, 20)
|
}, 20)
|
||||||
}, 150)
|
}, 150)
|
||||||
}
|
}
|
||||||
}, 30)
|
}, 30)
|
||||||
}, [onComplete, prefersReducedMotion])
|
}, [triggerComplete, prefersReducedMotion])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cursorInterval = setInterval(() => {
|
const cursorInterval = setInterval(() => {
|
||||||
@@ -79,48 +95,83 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
|||||||
className="fixed inset-0 flex items-center justify-center z-50"
|
className="fixed inset-0 flex items-center justify-center z-50"
|
||||||
style={{ backgroundColor: '#1E293B' }}
|
style={{ backgroundColor: '#1E293B' }}
|
||||||
>
|
>
|
||||||
<div
|
<motion.div
|
||||||
className="bg-white rounded-xl shadow-lg p-8"
|
className="bg-white p-8"
|
||||||
style={{
|
style={{
|
||||||
width: '320px',
|
width: '320px',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.3)',
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15), 0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||||
}}
|
}}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={isExiting ? { scale: 1.03, opacity: 0 } : { scale: 1, opacity: 1 }}
|
||||||
|
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center mb-6">
|
{/* Branding */}
|
||||||
|
<div className="flex flex-col items-center mb-8">
|
||||||
<div
|
<div
|
||||||
className="p-3 rounded-lg mb-4"
|
className="p-3 rounded-lg mb-3"
|
||||||
style={{ backgroundColor: 'rgba(0, 94, 184, 0.1)' }}
|
style={{ backgroundColor: 'rgba(0, 94, 184, 0.08)' }}
|
||||||
>
|
>
|
||||||
<Shield
|
<Shield
|
||||||
size={32}
|
size={28}
|
||||||
style={{ color: '#005EB8' }}
|
style={{ color: '#005EB8' }}
|
||||||
strokeWidth={2}
|
strokeWidth={2.5}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className="text-sm font-medium"
|
style={{
|
||||||
style={{ color: '#6B7280' }}
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#64748B',
|
||||||
|
letterSpacing: '0.01em',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
CareerRecord PMR
|
CareerRecord PMR
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 400,
|
||||||
|
color: '#94A3B8',
|
||||||
|
marginTop: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clinical Information System
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Login Form */}
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Username Field */}
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
className="block text-xs font-medium mb-1.5"
|
style={{
|
||||||
style={{ color: '#6B7280' }}
|
display: 'block',
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#64748B',
|
||||||
|
marginBottom: '6px',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Username
|
Username
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div
|
||||||
className="w-full px-3 py-2.5 rounded text-sm"
|
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Fira Code', monospace",
|
width: '100%',
|
||||||
backgroundColor: '#F9FAFB',
|
padding: '10px 12px',
|
||||||
border: '1px solid #E5E7EB',
|
fontFamily: "'Geist Mono', 'Courier New', monospace",
|
||||||
|
fontSize: '13px',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
border: '1px solid #D1D5DB',
|
||||||
|
borderRadius: '4px',
|
||||||
color: '#111827',
|
color: '#111827',
|
||||||
|
minHeight: '38px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{username}</span>
|
<span>{username}</span>
|
||||||
@@ -129,6 +180,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
|||||||
style={{
|
style={{
|
||||||
opacity: showCursor ? 1 : 0,
|
opacity: showCursor ? 1 : 0,
|
||||||
color: '#005EB8',
|
color: '#005EB8',
|
||||||
|
marginLeft: '1px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
|
||||||
@@ -137,21 +189,34 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Password Field */}
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
className="block text-xs font-medium mb-1.5"
|
style={{
|
||||||
style={{ color: '#6B7280' }}
|
display: 'block',
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#64748B',
|
||||||
|
marginBottom: '6px',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div
|
||||||
className="w-full px-3 py-2.5 rounded text-sm"
|
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Fira Code', monospace",
|
width: '100%',
|
||||||
backgroundColor: '#F9FAFB',
|
padding: '10px 12px',
|
||||||
border: '1px solid #E5E7EB',
|
fontFamily: "'Geist Mono', 'Courier New', monospace",
|
||||||
|
fontSize: '13px',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
border: '1px solid #D1D5DB',
|
||||||
|
borderRadius: '4px',
|
||||||
color: '#111827',
|
color: '#111827',
|
||||||
letterSpacing: '0.1em',
|
letterSpacing: '0.15em',
|
||||||
|
minHeight: '38px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{'\u2022'.repeat(passwordDots)}</span>
|
<span>{'\u2022'.repeat(passwordDots)}</span>
|
||||||
@@ -160,6 +225,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
|||||||
style={{
|
style={{
|
||||||
opacity: showCursor ? 1 : 0,
|
opacity: showCursor ? 1 : 0,
|
||||||
color: '#005EB8',
|
color: '#005EB8',
|
||||||
|
marginLeft: '2px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
|
||||||
@@ -168,27 +234,48 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Log In Button */}
|
||||||
<button
|
<button
|
||||||
className="w-full py-2.5 rounded text-sm font-semibold text-white transition-all duration-100"
|
|
||||||
style={{
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '11px 16px',
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#FFFFFF',
|
||||||
backgroundColor: buttonPressed ? '#004494' : '#005EB8',
|
backgroundColor: buttonPressed ? '#004494' : '#005EB8',
|
||||||
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
transform: buttonPressed ? 'scale(0.98)' : 'scale(1)',
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 100ms ease-out',
|
||||||
|
marginTop: '8px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Log In
|
Log In
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 pt-4 border-t border-gray-100">
|
{/* Footer */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '24px',
|
||||||
|
paddingTop: '20px',
|
||||||
|
borderTop: '1px solid #E5E7EB',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<p
|
<p
|
||||||
className="text-xs text-center"
|
style={{
|
||||||
style={{ color: '#9CA3AF' }}
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#94A3B8',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: '1.4',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Secure clinical system login
|
Secure clinical system login
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { ClipboardList, FileText, Pill, AlertTriangle, FlaskConical, FolderOpen, Send } from 'lucide-react'
|
||||||
|
import type { ViewId } from '../types/pmr'
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
id: ViewId
|
||||||
|
label: string
|
||||||
|
shortLabel: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ id: 'summary', label: 'Summary', shortLabel: 'Summary', icon: <ClipboardList size={20} /> },
|
||||||
|
{ id: 'consultations', label: 'Consultations', shortLabel: 'Consult', icon: <FileText size={20} /> },
|
||||||
|
{ id: 'medications', label: 'Medications', shortLabel: 'Meds', icon: <Pill size={20} /> },
|
||||||
|
{ id: 'problems', label: 'Problems', shortLabel: 'Issues', icon: <AlertTriangle size={20} /> },
|
||||||
|
{ id: 'investigations', label: 'Investigations', shortLabel: 'Tests', icon: <FlaskConical size={20} /> },
|
||||||
|
{ id: 'documents', label: 'Documents', shortLabel: 'Docs', icon: <FolderOpen size={20} /> },
|
||||||
|
{ id: 'referrals', label: 'Referrals', shortLabel: 'Refer', icon: <Send size={20} /> },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface MobileBottomNavProps {
|
||||||
|
activeView: ViewId
|
||||||
|
onViewChange: (view: ViewId) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileBottomNav({ activeView, onViewChange }: MobileBottomNavProps) {
|
||||||
|
const handleNavClick = (view: ViewId) => {
|
||||||
|
onViewChange(view)
|
||||||
|
window.location.hash = view
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
className="fixed bottom-0 left-0 right-0 z-50 bg-pmr-sidebar border-t border-white/10"
|
||||||
|
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||||
|
role="navigation"
|
||||||
|
aria-label="Mobile navigation"
|
||||||
|
>
|
||||||
|
<ul className="flex items-center justify-around h-14">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = activeView === item.id
|
||||||
|
return (
|
||||||
|
<li key={item.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleNavClick(item.id)}
|
||||||
|
className={`
|
||||||
|
flex flex-col items-center justify-center
|
||||||
|
w-12 h-14 rounded-lg
|
||||||
|
transition-colors duration-100
|
||||||
|
${isActive
|
||||||
|
? 'text-pmr-nhsblue'
|
||||||
|
: 'text-white/60 hover:text-white/90'}
|
||||||
|
`}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
aria-label={item.label}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<span className="text-[10px] mt-0.5 font-inter font-medium">
|
||||||
|
{item.shortLabel}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
+181
-10
@@ -1,7 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
|
import { motion, Variants } from 'framer-motion'
|
||||||
|
import { Search, X, ArrowLeft } from 'lucide-react'
|
||||||
import type { ViewId } from '../types/pmr'
|
import type { ViewId } from '../types/pmr'
|
||||||
import { ClinicalSidebar } from './ClinicalSidebar'
|
import { ClinicalSidebar } from './ClinicalSidebar'
|
||||||
import { PatientBanner } from './PatientBanner'
|
import { PatientBanner } from './PatientBanner'
|
||||||
|
import { MobileBottomNav } from './MobileBottomNav'
|
||||||
import { SummaryView } from './views/SummaryView'
|
import { SummaryView } from './views/SummaryView'
|
||||||
import { ConsultationsView } from './views/ConsultationsView'
|
import { ConsultationsView } from './views/ConsultationsView'
|
||||||
import { MedicationsView } from './views/MedicationsView'
|
import { MedicationsView } from './views/MedicationsView'
|
||||||
@@ -9,12 +12,14 @@ import { ProblemsView } from './views/ProblemsView'
|
|||||||
import { InvestigationsView } from './views/InvestigationsView'
|
import { InvestigationsView } from './views/InvestigationsView'
|
||||||
import { DocumentsView } from './views/DocumentsView'
|
import { DocumentsView } from './views/DocumentsView'
|
||||||
import { ReferralsView } from './views/ReferralsView'
|
import { ReferralsView } from './views/ReferralsView'
|
||||||
|
import { useAccessibility } from '../contexts/AccessibilityContext'
|
||||||
|
import { useBreakpoint } from '../hooks/useBreakpoint'
|
||||||
|
|
||||||
interface PMRInterfaceProps {
|
interface PMRInterfaceProps {
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PMRInterface({ children }: PMRInterfaceProps) {
|
function PMRContent({ children }: PMRInterfaceProps) {
|
||||||
const [activeView, setActiveView] = useState<ViewId>(() => {
|
const [activeView, setActiveView] = useState<ViewId>(() => {
|
||||||
const hash = window.location.hash.slice(1) as ViewId
|
const hash = window.location.hash.slice(1) as ViewId
|
||||||
const validViews: ViewId[] = [
|
const validViews: ViewId[] = [
|
||||||
@@ -29,14 +34,76 @@ export function PMRInterface({ children }: PMRInterfaceProps) {
|
|||||||
return validViews.includes(hash) ? hash : 'summary'
|
return validViews.includes(hash) ? hash : 'summary'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [mobileSearchQuery, setMobileSearchQuery] = useState('')
|
||||||
|
|
||||||
|
const viewHeadingRef = useRef<HTMLDivElement>(null)
|
||||||
|
const { requestFocusAfterViewChange, expandedItemId, setExpandedItem } = useAccessibility()
|
||||||
|
const { isMobile, isTablet } = useBreakpoint()
|
||||||
|
|
||||||
|
const prefersReducedMotion = typeof window !== 'undefined'
|
||||||
|
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
: false
|
||||||
|
|
||||||
|
const bannerVariants = useMemo<Variants>(() => ({
|
||||||
|
hidden: prefersReducedMotion ? {} : { y: -80, opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
y: 0,
|
||||||
|
opacity: 1,
|
||||||
|
transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }
|
||||||
|
}
|
||||||
|
}), [prefersReducedMotion])
|
||||||
|
|
||||||
|
const sidebarVariants = useMemo<Variants>(() => ({
|
||||||
|
hidden: prefersReducedMotion ? {} : { x: -220, opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
x: 0,
|
||||||
|
opacity: 1,
|
||||||
|
transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.25, ease: 'easeOut', delay: 0.05 }
|
||||||
|
}
|
||||||
|
}), [prefersReducedMotion])
|
||||||
|
|
||||||
|
const contentVariants = useMemo<Variants>(() => ({
|
||||||
|
hidden: prefersReducedMotion ? {} : { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.3, delay: 0.15 }
|
||||||
|
}
|
||||||
|
}), [prefersReducedMotion])
|
||||||
|
|
||||||
|
const mobileNavVariants = useMemo<Variants>(() => ({
|
||||||
|
hidden: prefersReducedMotion ? {} : { y: 56, opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
y: 0,
|
||||||
|
opacity: 1,
|
||||||
|
transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }
|
||||||
|
}
|
||||||
|
}), [prefersReducedMotion])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
requestFocusAfterViewChange()
|
||||||
|
if (viewHeadingRef.current) {
|
||||||
|
viewHeadingRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [activeView, requestFocusAfterViewChange])
|
||||||
|
|
||||||
const handleViewChange = (view: ViewId) => {
|
const handleViewChange = (view: ViewId) => {
|
||||||
setActiveView(view)
|
setActiveView(view)
|
||||||
|
if (expandedItemId) {
|
||||||
|
setExpandedItem(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNavigate = (view: ViewId, itemId?: string) => {
|
const handleNavigate = (view: ViewId) => {
|
||||||
void itemId
|
|
||||||
setActiveView(view)
|
setActiveView(view)
|
||||||
window.location.hash = view
|
window.location.hash = view
|
||||||
|
if (expandedItemId) {
|
||||||
|
setExpandedItem(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBackToSummary = () => {
|
||||||
|
handleViewChange('summary')
|
||||||
|
window.location.hash = 'summary'
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderView = () => {
|
const renderView = () => {
|
||||||
@@ -69,19 +136,123 @@ export function PMRInterface({ children }: PMRInterfaceProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const viewLabels: Record<ViewId, string> = {
|
||||||
|
summary: 'Patient Summary',
|
||||||
|
consultations: 'Consultation History',
|
||||||
|
medications: 'Current Medications',
|
||||||
|
problems: 'Problem List',
|
||||||
|
investigations: 'Investigation Results',
|
||||||
|
documents: 'Attached Documents',
|
||||||
|
referrals: 'Referral Form',
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-pmr-content">
|
<motion.div
|
||||||
<PatientBanner />
|
className="min-h-screen bg-pmr-content"
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
>
|
||||||
|
<motion.div variants={bannerVariants}>
|
||||||
|
<PatientBanner isMobile={isMobile} isTablet={isTablet} />
|
||||||
|
</motion.div>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<ClinicalSidebar activeView={activeView} onViewChange={handleViewChange} />
|
{!isMobile && (
|
||||||
<main
|
<motion.div variants={sidebarVariants}>
|
||||||
|
<ClinicalSidebar
|
||||||
|
activeView={activeView}
|
||||||
|
onViewChange={handleViewChange}
|
||||||
|
isTablet={isTablet}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
<motion.main
|
||||||
|
variants={contentVariants}
|
||||||
role="main"
|
role="main"
|
||||||
aria-label={`${activeView} view`}
|
aria-label={`${activeView} view`}
|
||||||
className="flex-1 min-h-[calc(100vh-80px)] p-6"
|
className={`
|
||||||
|
flex-1 p-4 md:p-6
|
||||||
|
${isMobile ? 'pb-20' : ''}
|
||||||
|
${isTablet ? 'min-h-[calc(100vh-48px)]' : 'min-h-[calc(100vh-80px)]'}
|
||||||
|
`}
|
||||||
>
|
>
|
||||||
|
{isMobile && (
|
||||||
|
<MobileSearchBar
|
||||||
|
query={mobileSearchQuery}
|
||||||
|
onChange={setMobileSearchQuery}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={viewHeadingRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="outline-none"
|
||||||
|
aria-label={viewLabels[activeView]}
|
||||||
|
>
|
||||||
|
<h1 className="sr-only">{viewLabels[activeView]}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMobile && activeView !== 'summary' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBackToSummary}
|
||||||
|
className="flex items-center gap-1 text-pmr-nhsblue text-sm font-inter font-medium mb-4 hover:underline"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} />
|
||||||
|
Back to Summary
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{children || renderView()}
|
{children || renderView()}
|
||||||
</main>
|
</motion.main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMobile && (
|
||||||
|
<motion.div variants={mobileNavVariants}>
|
||||||
|
<MobileBottomNav
|
||||||
|
activeView={activeView}
|
||||||
|
onViewChange={handleViewChange}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MobileSearchBarProps {
|
||||||
|
query: string
|
||||||
|
onChange: (query: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileSearchBar({ query, onChange }: MobileSearchBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
size={16}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search record..."
|
||||||
|
value={query}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
className="w-full h-10 pl-10 pr-10 bg-white border border-gray-200 rounded text-sm font-inter text-gray-900 placeholder-gray-400 focus:outline-none focus:border-pmr-nhsblue focus:ring-1 focus:ring-pmr-nhsblue/20 transition-colors"
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PMRInterface(props: PMRInterfaceProps) {
|
||||||
|
return <PMRContent {...props} />
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,31 @@
|
|||||||
import { Download, Mail, Linkedin } from 'lucide-react'
|
import { Download, Mail, Linkedin, MoreHorizontal } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
import { patient } from '@/data/patient'
|
import { patient } from '@/data/patient'
|
||||||
import { useScrollCondensation } from '@/hooks/useScrollCondensation'
|
import { useScrollCondensation } from '@/hooks/useScrollCondensation'
|
||||||
|
|
||||||
export function PatientBanner() {
|
interface PatientBannerProps {
|
||||||
|
isMobile?: boolean
|
||||||
|
isTablet?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PatientBanner({ isMobile = false, isTablet = false }: PatientBannerProps) {
|
||||||
const { isCondensed, sentinelRef } = useScrollCondensation({ threshold: 100 })
|
const { isCondensed, sentinelRef } = useScrollCondensation({ threshold: 100 })
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={sentinelRef}
|
||||||
|
className="h-0 w-full absolute top-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<MobileBanner />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldCondense = isTablet || isCondensed
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -17,11 +38,11 @@ export function PatientBanner() {
|
|||||||
sticky top-0 z-40 w-full
|
sticky top-0 z-40 w-full
|
||||||
bg-pmr-banner border-b border-slate-600
|
bg-pmr-banner border-b border-slate-600
|
||||||
transition-all duration-200 ease-out
|
transition-all duration-200 ease-out
|
||||||
${isCondensed ? 'h-12' : 'h-20'}
|
${shouldCondense ? 'h-12' : 'h-20'}
|
||||||
`}
|
`}
|
||||||
role="banner"
|
role="banner"
|
||||||
>
|
>
|
||||||
{isCondensed ? (
|
{shouldCondense ? (
|
||||||
<CondensedBanner />
|
<CondensedBanner />
|
||||||
) : (
|
) : (
|
||||||
<FullBanner />
|
<FullBanner />
|
||||||
@@ -31,6 +52,78 @@ export function PatientBanner() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MobileBanner() {
|
||||||
|
const [showOverflow, setShowOverflow] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className="sticky top-0 z-40 w-full h-12 bg-pmr-banner border-b border-slate-600"
|
||||||
|
role="banner"
|
||||||
|
>
|
||||||
|
<div className="h-full px-3 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
<h1 className="font-inter font-semibold text-white text-sm tracking-tight truncate">
|
||||||
|
CHARLWOOD, A (Mr)
|
||||||
|
</h1>
|
||||||
|
<span className="text-slate-500">|</span>
|
||||||
|
<span className="font-geist text-xs text-slate-300">
|
||||||
|
221 181 0
|
||||||
|
</span>
|
||||||
|
<StatusDot status="Active" />
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowOverflow(!showOverflow)}
|
||||||
|
className="p-2 text-white/70 hover:text-white transition-colors"
|
||||||
|
aria-label="Actions menu"
|
||||||
|
aria-expanded={showOverflow}
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={18} />
|
||||||
|
</button>
|
||||||
|
{showOverflow && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40"
|
||||||
|
onClick={() => setShowOverflow(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-0 top-full mt-1 w-40 bg-white border border-gray-200 rounded shadow-lg z-50 py-1">
|
||||||
|
<a
|
||||||
|
href="/cv.pdf"
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
onClick={() => setShowOverflow(false)}
|
||||||
|
>
|
||||||
|
<Download size={14} />
|
||||||
|
Download CV
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`mailto:${patient.email}`}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
onClick={() => setShowOverflow(false)}
|
||||||
|
>
|
||||||
|
<Mail size={14} />
|
||||||
|
Email
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`https://${patient.linkedin}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
onClick={() => setShowOverflow(false)}
|
||||||
|
>
|
||||||
|
<Linkedin size={14} />
|
||||||
|
LinkedIn
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function FullBanner() {
|
function FullBanner() {
|
||||||
return (
|
return (
|
||||||
<div className="h-full px-4 lg:px-6 flex flex-col justify-center">
|
<div className="h-full px-4 lg:px-6 flex flex-col justify-center">
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ function ConsultationEntry({
|
|||||||
prefersReducedMotion,
|
prefersReducedMotion,
|
||||||
}: ConsultationEntryProps) {
|
}: ConsultationEntryProps) {
|
||||||
const contentRef = useRef<HTMLDivElement>(null)
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
|
const expandedContentRef = useRef<HTMLDivElement>(null)
|
||||||
const [height, setHeight] = useState<number | undefined>(isExpanded ? undefined : 0)
|
const [height, setHeight] = useState<number | undefined>(isExpanded ? undefined : 0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -75,6 +76,12 @@ function ConsultationEntry({
|
|||||||
setHeight(0)
|
setHeight(0)
|
||||||
}, [isExpanded, prefersReducedMotion])
|
}, [isExpanded, prefersReducedMotion])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isExpanded && expandedContentRef.current) {
|
||||||
|
expandedContentRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [isExpanded])
|
||||||
|
|
||||||
const keyCodedEntry = consultation.codedEntries[0]
|
const keyCodedEntry = consultation.codedEntries[0]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -134,6 +141,7 @@ function ConsultationEntry({
|
|||||||
<ExpandedContent
|
<ExpandedContent
|
||||||
consultation={consultation}
|
consultation={consultation}
|
||||||
prefersReducedMotion={prefersReducedMotion}
|
prefersReducedMotion={prefersReducedMotion}
|
||||||
|
contentRef={expandedContentRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -162,15 +170,18 @@ function StatusDot({ isCurrent }: StatusDotProps) {
|
|||||||
interface ExpandedContentProps {
|
interface ExpandedContentProps {
|
||||||
consultation: Consultation
|
consultation: Consultation
|
||||||
prefersReducedMotion: boolean
|
prefersReducedMotion: boolean
|
||||||
|
contentRef: React.RefObject<HTMLDivElement>
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExpandedContent({ consultation, prefersReducedMotion }: ExpandedContentProps) {
|
function ExpandedContent({ consultation, prefersReducedMotion, contentRef }: ExpandedContentProps) {
|
||||||
const opacity = prefersReducedMotion ? 1 : undefined
|
const opacity = prefersReducedMotion ? 1 : undefined
|
||||||
const transition = prefersReducedMotion ? 'none' : 'opacity 150ms ease-out'
|
const transition = prefersReducedMotion ? 'none' : 'opacity 150ms ease-out'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="px-4 pb-4"
|
ref={contentRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="px-4 pb-4 outline-none"
|
||||||
style={{ opacity, transition }}
|
style={{ opacity, transition }}
|
||||||
>
|
>
|
||||||
<div className="pl-5 border-l border-gray-200 ml-1">
|
<div className="pl-5 border-l border-gray-200 ml-1">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
|
|||||||
import { ChevronDown, ChevronUp, FileText, Award, GraduationCap, FlaskConical } from 'lucide-react'
|
import { ChevronDown, ChevronUp, FileText, Award, GraduationCap, FlaskConical } from 'lucide-react'
|
||||||
import { documents } from '@/data/documents'
|
import { documents } from '@/data/documents'
|
||||||
import type { Document, DocumentType } from '@/types/pmr'
|
import type { Document, DocumentType } from '@/types/pmr'
|
||||||
|
import { useBreakpoint } from '@/hooks/useBreakpoint'
|
||||||
|
|
||||||
function DocumentTypeIcon({ type }: { type: DocumentType }) {
|
function DocumentTypeIcon({ type }: { type: DocumentType }) {
|
||||||
const iconMap: Record<DocumentType, React.ReactNode> = {
|
const iconMap: Record<DocumentType, React.ReactNode> = {
|
||||||
@@ -136,15 +137,110 @@ function DocumentRow({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MobileDocumentCard({
|
||||||
|
document,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
document: Document
|
||||||
|
isExpanded: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="w-full p-4 text-left"
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<DocumentTypeIcon type={document.type} />
|
||||||
|
<span className="text-xs text-gray-500">{document.type}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-inter font-medium text-sm text-gray-900">
|
||||||
|
{document.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-1.5 text-xs text-gray-500">
|
||||||
|
<span className="font-geist">{document.date}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{document.source}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 mt-1">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp size={16} className="text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={16} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-4 pb-4 border-t border-gray-100">
|
||||||
|
<div className="pt-3 font-mono text-xs text-gray-700 leading-relaxed space-y-2">
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-gray-400 w-28 shrink-0">Type:</span>
|
||||||
|
<span>{document.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-gray-400 w-28 shrink-0">Date Awarded:</span>
|
||||||
|
<span>{document.date}</span>
|
||||||
|
</div>
|
||||||
|
{document.institution && (
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-gray-400 w-28 shrink-0">Institution:</span>
|
||||||
|
<span>{document.institution}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{document.classification && (
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-gray-400 w-28 shrink-0">Classification:</span>
|
||||||
|
<span>{document.classification}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{document.duration && (
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-gray-400 w-28 shrink-0">Duration:</span>
|
||||||
|
<span>{document.duration}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{document.researchDetail && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-gray-400 w-28 shrink-0">Research:</span>
|
||||||
|
<span className="mt-1">
|
||||||
|
{document.researchDetail}
|
||||||
|
{document.researchGrade && (
|
||||||
|
<><br />Grade: {document.researchGrade}</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{document.notes && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-gray-400 w-28 shrink-0">Notes:</span>
|
||||||
|
<span className="mt-1 text-gray-600">{document.notes}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function DocumentsView() {
|
export function DocumentsView() {
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
const { isMobile } = useBreakpoint()
|
||||||
|
|
||||||
const handleToggle = (id: string) => {
|
const handleToggle = (id: string) => {
|
||||||
setExpandedId(expandedId === id ? null : id)
|
setExpandedId(expandedId === id ? null : id)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border border-gray-200 rounded">
|
<div className="bg-white border border-gray-200 rounded overflow-hidden">
|
||||||
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
|
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
|
||||||
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
|
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
|
||||||
Attached Documents
|
Attached Documents
|
||||||
@@ -153,54 +249,67 @@ export function DocumentsView() {
|
|||||||
Education and certifications presented as attached documents in the patient record.
|
Education and certifications presented as attached documents in the patient record.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
{isMobile ? (
|
||||||
<table className="w-full border-collapse">
|
<div className="p-3 space-y-3 bg-pmr-content">
|
||||||
<thead>
|
{documents.map((document) => (
|
||||||
<tr className="bg-gray-50">
|
<MobileDocumentCard
|
||||||
<th
|
key={document.id}
|
||||||
scope="col"
|
document={document}
|
||||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-12"
|
isExpanded={expandedId === document.id}
|
||||||
>
|
onToggle={() => handleToggle(document.id)}
|
||||||
Type
|
/>
|
||||||
</th>
|
))}
|
||||||
<th
|
</div>
|
||||||
scope="col"
|
) : (
|
||||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
<div className="overflow-x-auto">
|
||||||
>
|
<table className="w-full border-collapse">
|
||||||
Document
|
<thead>
|
||||||
</th>
|
<tr className="bg-gray-50">
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-20"
|
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-12"
|
||||||
>
|
>
|
||||||
Date
|
Type
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-32"
|
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||||
>
|
>
|
||||||
Source
|
Document
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-10"
|
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-20"
|
||||||
>
|
>
|
||||||
<span className="sr-only">Expand</span>
|
Date
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
<th
|
||||||
</thead>
|
scope="col"
|
||||||
<tbody>
|
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-32"
|
||||||
{documents.map((document) => (
|
>
|
||||||
<DocumentRow
|
Source
|
||||||
key={document.id}
|
</th>
|
||||||
document={document}
|
<th
|
||||||
isExpanded={expandedId === document.id}
|
scope="col"
|
||||||
onToggle={() => handleToggle(document.id)}
|
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-10"
|
||||||
/>
|
>
|
||||||
))}
|
<span className="sr-only">Expand</span>
|
||||||
</tbody>
|
</th>
|
||||||
</table>
|
</tr>
|
||||||
</div>
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{documents.map((document) => (
|
||||||
|
<DocumentRow
|
||||||
|
key={document.id}
|
||||||
|
document={document}
|
||||||
|
isExpanded={expandedId === document.id}
|
||||||
|
onToggle={() => handleToggle(document.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{documents.length === 0 && (
|
{documents.length === 0 && (
|
||||||
<div className="p-4 text-sm text-gray-500 text-center">No documents attached</div>
|
<div className="p-4 text-sm text-gray-500 text-center">No documents attached</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
|
|||||||
import { ChevronDown, ChevronUp, ExternalLink, Circle } from 'lucide-react'
|
import { ChevronDown, ChevronUp, ExternalLink, Circle } from 'lucide-react'
|
||||||
import { investigations } from '@/data/investigations'
|
import { investigations } from '@/data/investigations'
|
||||||
import type { Investigation } from '@/types/pmr'
|
import type { Investigation } from '@/types/pmr'
|
||||||
|
import { useBreakpoint } from '@/hooks/useBreakpoint'
|
||||||
|
|
||||||
type InvestigationStatus = 'Complete' | 'Ongoing' | 'Live'
|
type InvestigationStatus = 'Complete' | 'Ongoing' | 'Live'
|
||||||
|
|
||||||
@@ -151,7 +152,7 @@ function InvestigationRow({
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-nhsblue text-white text-sm font-medium rounded hover:bg-blue-700 transition-colors"
|
className="inline-flex items-center gap-2 px-4 py-2 bg-pmr-nhsblue text-white text-sm font-medium rounded hover:bg-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
View Results
|
View Results
|
||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
@@ -166,15 +167,120 @@ function InvestigationRow({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MobileInvestigationCard({
|
||||||
|
investigation,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
investigation: Investigation
|
||||||
|
isExpanded: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="w-full p-4 text-left"
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-inter font-medium text-sm text-gray-900">
|
||||||
|
{investigation.name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
|
||||||
|
<span className="font-geist">{investigation.requestedYear}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<StatusBadge status={investigation.status} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-700 mt-2 line-clamp-2">
|
||||||
|
{investigation.resultSummary}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 mt-1">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp size={16} className="text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={16} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-4 pb-4 border-t border-gray-100">
|
||||||
|
<div className="pt-3 font-mono text-xs text-gray-700 leading-relaxed space-y-2">
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-gray-400 w-28 shrink-0">Date Requested:</span>
|
||||||
|
<span>{investigation.requestedYear}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-gray-400 w-28 shrink-0">Date Reported:</span>
|
||||||
|
<span>{investigation.reportedYear ?? 'Pending'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-gray-400 w-28 shrink-0">Status:</span>
|
||||||
|
<span>
|
||||||
|
{investigation.status}
|
||||||
|
{investigation.status === 'Live' && investigation.externalUrl && (
|
||||||
|
<> — Live at {investigation.externalUrl.replace('https://', '')}</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-gray-400 w-28 shrink-0">Clinician:</span>
|
||||||
|
<span>{investigation.requestingClinician}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-gray-400 w-28 shrink-0">Methodology:</span>
|
||||||
|
<span className="mt-1">{investigation.methodology}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-gray-400 w-28 shrink-0">Results:</span>
|
||||||
|
<ul className="mt-1 space-y-0.5">
|
||||||
|
{investigation.results.map((result, idx) => (
|
||||||
|
<li key={idx} className="flex items-start gap-2">
|
||||||
|
<span className="text-gray-300 mt-0.5">-</span>
|
||||||
|
<span>{result}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-gray-400 w-28 shrink-0">Tech Stack:</span>
|
||||||
|
<span>{investigation.techStack.join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{investigation.externalUrl && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<a
|
||||||
|
href={investigation.externalUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-pmr-nhsblue text-white text-xs font-medium rounded hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
View Results
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function InvestigationsView() {
|
export function InvestigationsView() {
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
const { isMobile } = useBreakpoint()
|
||||||
|
|
||||||
const handleToggle = (id: string) => {
|
const handleToggle = (id: string) => {
|
||||||
setExpandedId(expandedId === id ? null : id)
|
setExpandedId(expandedId === id ? null : id)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border border-gray-200 rounded">
|
<div className="bg-white border border-gray-200 rounded overflow-hidden">
|
||||||
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
|
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
|
||||||
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
|
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
|
||||||
Investigation Results
|
Investigation Results
|
||||||
@@ -183,54 +289,67 @@ export function InvestigationsView() {
|
|||||||
Projects presented as diagnostic investigations — tests that were ordered, performed, and returned results.
|
Projects presented as diagnostic investigations — tests that were ordered, performed, and returned results.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
{isMobile ? (
|
||||||
<table className="w-full border-collapse">
|
<div className="p-3 space-y-3 bg-pmr-content">
|
||||||
<thead>
|
{investigations.map((investigation) => (
|
||||||
<tr className="bg-gray-50">
|
<MobileInvestigationCard
|
||||||
<th
|
key={investigation.id}
|
||||||
scope="col"
|
investigation={investigation}
|
||||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
isExpanded={expandedId === investigation.id}
|
||||||
>
|
onToggle={() => handleToggle(investigation.id)}
|
||||||
Test Name
|
/>
|
||||||
</th>
|
))}
|
||||||
<th
|
</div>
|
||||||
scope="col"
|
) : (
|
||||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-24"
|
<div className="overflow-x-auto">
|
||||||
>
|
<table className="w-full border-collapse">
|
||||||
Requested
|
<thead>
|
||||||
</th>
|
<tr className="bg-gray-50">
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
|
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||||
>
|
>
|
||||||
Status
|
Test Name
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-24"
|
||||||
>
|
>
|
||||||
Result
|
Requested
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-10"
|
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
|
||||||
>
|
>
|
||||||
<span className="sr-only">Expand</span>
|
Status
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
<th
|
||||||
</thead>
|
scope="col"
|
||||||
<tbody>
|
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||||
{investigations.map((investigation) => (
|
>
|
||||||
<InvestigationRow
|
Result
|
||||||
key={investigation.id}
|
</th>
|
||||||
investigation={investigation}
|
<th
|
||||||
isExpanded={expandedId === investigation.id}
|
scope="col"
|
||||||
onToggle={() => handleToggle(investigation.id)}
|
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-10"
|
||||||
/>
|
>
|
||||||
))}
|
<span className="sr-only">Expand</span>
|
||||||
</tbody>
|
</th>
|
||||||
</table>
|
</tr>
|
||||||
</div>
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{investigations.map((investigation) => (
|
||||||
|
<InvestigationRow
|
||||||
|
key={investigation.id}
|
||||||
|
investigation={investigation}
|
||||||
|
isExpanded={expandedId === investigation.id}
|
||||||
|
onToggle={() => handleToggle(investigation.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{investigations.length === 0 && (
|
{investigations.length === 0 && (
|
||||||
<div className="p-4 text-sm text-gray-500 text-center">No investigation results</div>
|
<div className="p-4 text-sm text-gray-500 text-center">No investigation results</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useMemo } from 'react'
|
|||||||
import { ChevronDown, ChevronUp, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
|
import { ChevronDown, ChevronUp, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
|
||||||
import { medications } from '@/data/medications'
|
import { medications } from '@/data/medications'
|
||||||
import type { Medication } from '@/types/pmr'
|
import type { Medication } from '@/types/pmr'
|
||||||
|
import { useBreakpoint } from '@/hooks/useBreakpoint'
|
||||||
|
|
||||||
type SortField = 'name' | 'dose' | 'frequency' | 'startYear' | 'status'
|
type SortField = 'name' | 'dose' | 'frequency' | 'startYear' | 'status'
|
||||||
type SortDirection = 'asc' | 'desc' | null
|
type SortDirection = 'asc' | 'desc' | null
|
||||||
@@ -12,15 +13,16 @@ interface SortState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const categoryTabs = [
|
const categoryTabs = [
|
||||||
{ id: 'Active', label: 'Active Medications', description: 'Technical skills (daily use)' },
|
{ id: 'Active', label: 'Active Medications', shortLabel: 'Active', description: 'Technical skills (daily use)' },
|
||||||
{ id: 'Clinical', label: 'Clinical Medications', description: 'Healthcare domain skills' },
|
{ id: 'Clinical', label: 'Clinical Medications', shortLabel: 'Clinical', description: 'Healthcare domain skills' },
|
||||||
{ id: 'PRN', label: 'PRN (As Required)', description: 'Strategic & leadership skills' },
|
{ id: 'PRN', label: 'PRN (As Required)', shortLabel: 'PRN', description: 'Strategic & leadership skills' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export function MedicationsView() {
|
export function MedicationsView() {
|
||||||
const [activeTab, setActiveTab] = useState<'Active' | 'Clinical' | 'PRN'>('Active')
|
const [activeTab, setActiveTab] = useState<'Active' | 'Clinical' | 'PRN'>('Active')
|
||||||
const [expandedRow, setExpandedRow] = useState<string | null>(null)
|
const [expandedRow, setExpandedRow] = useState<string | null>(null)
|
||||||
const [sort, setSort] = useState<SortState>({ field: 'name', direction: null })
|
const [sort, setSort] = useState<SortState>({ field: 'name', direction: null })
|
||||||
|
const { isMobile } = useBreakpoint()
|
||||||
|
|
||||||
const prefersReducedMotion = typeof window !== 'undefined'
|
const prefersReducedMotion = typeof window !== 'undefined'
|
||||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
@@ -117,101 +119,112 @@ export function MedicationsView() {
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<span className={`font-inter font-medium text-sm ${activeTab === tab.id ? 'text-gray-900' : 'text-gray-600'}`}>
|
<span className={`font-inter font-medium text-sm ${activeTab === tab.id ? 'text-gray-900' : 'text-gray-600'}`}>
|
||||||
{tab.label}
|
{isMobile ? tab.shortLabel : tab.label}
|
||||||
</span>
|
|
||||||
<span className="block font-inter text-xs text-gray-500 mt-0.5">
|
|
||||||
{tab.description}
|
|
||||||
</span>
|
</span>
|
||||||
|
{!isMobile && (
|
||||||
|
<span className="block font-inter text-xs text-gray-500 mt-0.5">
|
||||||
|
{tab.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
{isMobile ? (
|
||||||
<table className="w-full" role="grid">
|
<MobileMedicationList
|
||||||
<thead>
|
medications={sortedMedications}
|
||||||
<tr className="border-b border-gray-200 bg-gray-50">
|
expandedRow={expandedRow}
|
||||||
<th scope="col" className="w-8"></th>
|
onToggle={toggleRow}
|
||||||
<th scope="col" className="text-left">
|
prefersReducedMotion={prefersReducedMotion}
|
||||||
<button
|
/>
|
||||||
type="button"
|
) : (
|
||||||
onClick={() => handleSort('name')}
|
<div className="overflow-x-auto">
|
||||||
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
|
<table className="w-full" role="grid">
|
||||||
>
|
<thead>
|
||||||
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
<tr className="border-b border-gray-200 bg-gray-50">
|
||||||
Drug Name
|
<th scope="col" className="w-8"></th>
|
||||||
</span>
|
<th scope="col" className="text-left">
|
||||||
{getSortIcon('name')}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
</th>
|
onClick={() => handleSort('name')}
|
||||||
<th scope="col" className="text-left">
|
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
|
||||||
<button
|
>
|
||||||
type="button"
|
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||||
onClick={() => handleSort('dose')}
|
Drug Name
|
||||||
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
|
</span>
|
||||||
>
|
{getSortIcon('name')}
|
||||||
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
</button>
|
||||||
Dose
|
</th>
|
||||||
</span>
|
<th scope="col" className="text-left">
|
||||||
{getSortIcon('dose')}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
</th>
|
onClick={() => handleSort('dose')}
|
||||||
<th scope="col" className="text-left">
|
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
|
||||||
<button
|
>
|
||||||
type="button"
|
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||||
onClick={() => handleSort('frequency')}
|
Dose
|
||||||
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
|
</span>
|
||||||
>
|
{getSortIcon('dose')}
|
||||||
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
</button>
|
||||||
Frequency
|
</th>
|
||||||
</span>
|
<th scope="col" className="text-left">
|
||||||
{getSortIcon('frequency')}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
</th>
|
onClick={() => handleSort('frequency')}
|
||||||
<th scope="col" className="text-left">
|
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
|
||||||
<button
|
>
|
||||||
type="button"
|
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||||
onClick={() => handleSort('startYear')}
|
Frequency
|
||||||
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
|
</span>
|
||||||
>
|
{getSortIcon('frequency')}
|
||||||
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
</button>
|
||||||
Start
|
</th>
|
||||||
</span>
|
<th scope="col" className="text-left">
|
||||||
{getSortIcon('startYear')}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
</th>
|
onClick={() => handleSort('startYear')}
|
||||||
<th scope="col" className="text-left">
|
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
|
||||||
<button
|
>
|
||||||
type="button"
|
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||||
onClick={() => handleSort('status')}
|
Start
|
||||||
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
|
</span>
|
||||||
>
|
{getSortIcon('startYear')}
|
||||||
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
</button>
|
||||||
Status
|
</th>
|
||||||
</span>
|
<th scope="col" className="text-left">
|
||||||
{getSortIcon('status')}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
</th>
|
onClick={() => handleSort('status')}
|
||||||
</tr>
|
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
|
||||||
</thead>
|
>
|
||||||
<tbody>
|
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||||
{sortedMedications.map((med, index) => (
|
Status
|
||||||
<MedicationRow
|
</span>
|
||||||
key={med.id}
|
{getSortIcon('status')}
|
||||||
medication={med}
|
</button>
|
||||||
isExpanded={expandedRow === med.id}
|
</th>
|
||||||
isAlternating={index % 2 === 1}
|
</tr>
|
||||||
onToggle={() => toggleRow(med.id)}
|
</thead>
|
||||||
prefersReducedMotion={prefersReducedMotion}
|
<tbody>
|
||||||
/>
|
{sortedMedications.map((med, index) => (
|
||||||
))}
|
<MedicationRow
|
||||||
</tbody>
|
key={med.id}
|
||||||
</table>
|
medication={med}
|
||||||
</div>
|
isExpanded={expandedRow === med.id}
|
||||||
|
isAlternating={index % 2 === 1}
|
||||||
|
onToggle={() => toggleRow(med.id)}
|
||||||
|
prefersReducedMotion={prefersReducedMotion}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50">
|
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50">
|
||||||
<p className="font-inter text-xs text-gray-500">
|
<p className="font-inter text-xs text-gray-500">
|
||||||
{sortedMedications.length} medications in this category. Click a row to view prescribing history.
|
{sortedMedications.length} medications in this category. {isMobile ? 'Tap' : 'Click'} a row to view prescribing history.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,6 +232,80 @@ export function MedicationsView() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MobileMedicationListProps {
|
||||||
|
medications: Medication[]
|
||||||
|
expandedRow: string | null
|
||||||
|
onToggle: (id: string) => void
|
||||||
|
prefersReducedMotion: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileMedicationList({ medications, expandedRow, onToggle, prefersReducedMotion }: MobileMedicationListProps) {
|
||||||
|
const statusColors = {
|
||||||
|
'Active': 'bg-green-500',
|
||||||
|
'Historical': 'bg-gray-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="divide-y divide-gray-200">
|
||||||
|
{medications.map((med) => (
|
||||||
|
<div key={med.id} className="bg-white">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(med.id)}
|
||||||
|
className="w-full p-4 text-left"
|
||||||
|
aria-expanded={expandedRow === med.id}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-inter font-medium text-sm text-gray-900">
|
||||||
|
{med.name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
|
||||||
|
<span className="font-geist">{med.dose}%</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{med.frequency}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="font-geist">Since {med.startYear}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${statusColors[med.status]}`} />
|
||||||
|
<span className="text-xs text-gray-600">{med.status}</span>
|
||||||
|
{expandedRow === med.id ? (
|
||||||
|
<ChevronUp size={16} className="text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={16} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{expandedRow === med.id && (
|
||||||
|
<div className={`px-4 pb-4 ${prefersReducedMotion ? '' : 'animate-fadeIn'}`}>
|
||||||
|
<div className="bg-gray-50 rounded p-3">
|
||||||
|
<p className="font-inter font-medium text-xs uppercase tracking-wide text-gray-400 mb-2">
|
||||||
|
Prescribing History
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{med.prescribingHistory.map((entry, index) => (
|
||||||
|
<div key={index} className="flex gap-3">
|
||||||
|
<span className="font-geist font-medium text-xs text-gray-500 w-10 flex-shrink-0">
|
||||||
|
{entry.year}
|
||||||
|
</span>
|
||||||
|
<span className="font-geist text-xs text-gray-600">
|
||||||
|
{entry.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface MedicationRowProps {
|
interface MedicationRowProps {
|
||||||
medication: Medication
|
medication: Medication
|
||||||
isExpanded: boolean
|
isExpanded: boolean
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ChevronDown, ChevronUp, ExternalLink } from 'lucide-react'
|
|||||||
import { problems } from '@/data/problems'
|
import { problems } from '@/data/problems'
|
||||||
import { consultations } from '@/data/consultations'
|
import { consultations } from '@/data/consultations'
|
||||||
import type { Problem, Consultation } from '@/types/pmr'
|
import type { Problem, Consultation } from '@/types/pmr'
|
||||||
|
import { useBreakpoint } from '@/hooks/useBreakpoint'
|
||||||
|
|
||||||
interface ProblemsViewProps {
|
interface ProblemsViewProps {
|
||||||
onNavigate?: (view: 'consultations', itemId?: string) => void
|
onNavigate?: (view: 'consultations', itemId?: string) => void
|
||||||
@@ -135,7 +136,7 @@ function ProblemRow({
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleLinkedClick(consultation.id)
|
handleLinkedClick(consultation.id)
|
||||||
}}
|
}}
|
||||||
className="inline-flex items-center gap-1 text-xs text-nhsblue hover:underline"
|
className="inline-flex items-center gap-1 text-xs text-pmr-nhsblue hover:underline"
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-3 h-3" />
|
<ExternalLink className="w-3 h-3" />
|
||||||
{consultation.organization} — {consultation.role}
|
{consultation.organization} — {consultation.role}
|
||||||
@@ -152,8 +153,101 @@ function ProblemRow({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MobileProblemCard({
|
||||||
|
problem,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
onNavigate,
|
||||||
|
showOutcome,
|
||||||
|
}: {
|
||||||
|
problem: Problem
|
||||||
|
isExpanded: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
onNavigate?: (view: 'consultations', itemId?: string) => void
|
||||||
|
showOutcome: boolean
|
||||||
|
}) {
|
||||||
|
const linkedConsultations = (problem.linkedConsultations ?? [])
|
||||||
|
.map((id) => consultations.find((c) => c.id === id))
|
||||||
|
.filter((c): c is Consultation => c !== undefined)
|
||||||
|
|
||||||
|
const handleLinkedClick = (consultationId: string) => {
|
||||||
|
if (onNavigate) {
|
||||||
|
onNavigate('consultations', consultationId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="w-full p-4 text-left"
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<TrafficLight status={problem.status} />
|
||||||
|
<span className="font-mono text-xs text-gray-500">[{problem.code}]</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-inter font-medium text-sm text-gray-900">
|
||||||
|
{problem.description}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-1.5 text-xs text-gray-500">
|
||||||
|
<span>{showOutcome ? 'Resolved' : 'Since'}: {problem.resolved || problem.since}</span>
|
||||||
|
{showOutcome && problem.outcome && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-gray-700">{problem.outcome}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 mt-1">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp size={16} className="text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={16} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-4 pb-4 border-t border-gray-100">
|
||||||
|
<div className="pt-3 text-sm text-gray-700 leading-relaxed">
|
||||||
|
{problem.narrative}
|
||||||
|
</div>
|
||||||
|
{linkedConsultations.length > 0 && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||||
|
Linked Consultations:
|
||||||
|
</span>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{linkedConsultations.map((consultation) => (
|
||||||
|
<button
|
||||||
|
key={consultation.id}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleLinkedClick(consultation.id)
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-pmr-nhsblue hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
{consultation.organization}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function ProblemsView({ onNavigate }: ProblemsViewProps) {
|
export function ProblemsView({ onNavigate }: ProblemsViewProps) {
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
const { isMobile } = useBreakpoint()
|
||||||
|
|
||||||
const activeProblems = problems.filter(
|
const activeProblems = problems.filter(
|
||||||
(p) => p.status === 'Active' || p.status === 'In Progress'
|
(p) => p.status === 'Active' || p.status === 'In Progress'
|
||||||
@@ -166,50 +260,16 @@ export function ProblemsView({ onNavigate }: ProblemsViewProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="bg-white border border-gray-200 rounded">
|
<div className="bg-white border border-gray-200 rounded overflow-hidden">
|
||||||
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
|
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
|
||||||
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
|
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
|
||||||
Active Problems
|
Active Problems
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full border-collapse">
|
{isMobile ? (
|
||||||
<thead>
|
<div className="p-3 space-y-3 bg-pmr-content">
|
||||||
<tr className="bg-gray-50">
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
|
|
||||||
>
|
|
||||||
Status
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
|
|
||||||
>
|
|
||||||
Code
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
|
||||||
>
|
|
||||||
Problem
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
|
|
||||||
>
|
|
||||||
Since
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-10"
|
|
||||||
>
|
|
||||||
<span className="sr-only">Expand</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{activeProblems.map((problem) => (
|
{activeProblems.map((problem) => (
|
||||||
<ProblemRow
|
<MobileProblemCard
|
||||||
key={problem.id}
|
key={problem.id}
|
||||||
problem={problem}
|
problem={problem}
|
||||||
isExpanded={expandedId === problem.id}
|
isExpanded={expandedId === problem.id}
|
||||||
@@ -218,63 +278,72 @@ export function ProblemsView({ onNavigate }: ProblemsViewProps) {
|
|||||||
showOutcome={false}
|
showOutcome={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
) : (
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50">
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
|
||||||
|
>
|
||||||
|
Code
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||||
|
>
|
||||||
|
Problem
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
|
||||||
|
>
|
||||||
|
Since
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-10"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Expand</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{activeProblems.map((problem) => (
|
||||||
|
<ProblemRow
|
||||||
|
key={problem.id}
|
||||||
|
problem={problem}
|
||||||
|
isExpanded={expandedId === problem.id}
|
||||||
|
onToggle={() => handleToggle(problem.id)}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
showOutcome={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
{activeProblems.length === 0 && (
|
{activeProblems.length === 0 && (
|
||||||
<div className="p-4 text-sm text-gray-500 text-center">No active problems</div>
|
<div className="p-4 text-sm text-gray-500 text-center">No active problems</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border border-gray-200 rounded">
|
<div className="bg-white border border-gray-200 rounded overflow-hidden">
|
||||||
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
|
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
|
||||||
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
|
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
|
||||||
Resolved Problems
|
Resolved Problems
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full border-collapse">
|
{isMobile ? (
|
||||||
<thead>
|
<div className="p-3 space-y-3 bg-pmr-content">
|
||||||
<tr className="bg-gray-50">
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
|
|
||||||
>
|
|
||||||
Status
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
|
|
||||||
>
|
|
||||||
Code
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
|
||||||
>
|
|
||||||
Problem
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
|
|
||||||
>
|
|
||||||
Resolved
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
|
||||||
>
|
|
||||||
Outcome
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-10"
|
|
||||||
>
|
|
||||||
<span className="sr-only">Expand</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{resolvedProblems.map((problem) => (
|
{resolvedProblems.map((problem) => (
|
||||||
<ProblemRow
|
<MobileProblemCard
|
||||||
key={problem.id}
|
key={problem.id}
|
||||||
problem={problem}
|
problem={problem}
|
||||||
isExpanded={expandedId === problem.id}
|
isExpanded={expandedId === problem.id}
|
||||||
@@ -283,8 +352,63 @@ export function ProblemsView({ onNavigate }: ProblemsViewProps) {
|
|||||||
showOutcome={true}
|
showOutcome={true}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
) : (
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50">
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
|
||||||
|
>
|
||||||
|
Code
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||||
|
>
|
||||||
|
Problem
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
|
||||||
|
>
|
||||||
|
Resolved
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||||
|
>
|
||||||
|
Outcome
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-10"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Expand</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{resolvedProblems.map((problem) => (
|
||||||
|
<ProblemRow
|
||||||
|
key={problem.id}
|
||||||
|
problem={problem}
|
||||||
|
isExpanded={expandedId === problem.id}
|
||||||
|
onToggle={() => handleToggle(problem.id)}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
showOutcome={true}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
{resolvedProblems.length === 0 && (
|
{resolvedProblems.length === 0 && (
|
||||||
<div className="p-4 text-sm text-gray-500 text-center">No resolved problems</div>
|
<div className="p-4 text-sm text-gray-500 text-center">No resolved problems</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -279,16 +279,16 @@ function QuickMedsCard({ medications, onNavigate }: QuickMedsCardProps) {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-200">
|
<tr className="border-b border-gray-200">
|
||||||
<th className="px-4 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
<th scope="col" className="px-4 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||||
Drug
|
Drug
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
<th scope="col" className="px-4 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||||
Dose
|
Dose
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
<th scope="col" className="px-4 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||||
Freq
|
Freq
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
<th scope="col" className="px-4 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, useRef, useEffect, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface AccessibilityContextValue {
|
||||||
|
expandedItemId: string | null
|
||||||
|
setExpandedItem: (id: string | null) => void
|
||||||
|
requestFocusAfterLogin: () => void
|
||||||
|
focusAfterLoginRef: React.RefObject<HTMLButtonElement | null>
|
||||||
|
focusAfterViewChangeRef: React.RefObject<HTMLHeadingElement | null>
|
||||||
|
requestFocusAfterViewChange: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccessibilityContext = createContext<AccessibilityContextValue | null>(null)
|
||||||
|
|
||||||
|
export function AccessibilityProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [expandedItemId, setExpandedItemId] = useState<string | null>(null)
|
||||||
|
const focusAfterLoginRef = useRef<HTMLButtonElement | null>(null)
|
||||||
|
const focusAfterViewChangeRef = useRef<HTMLHeadingElement | null>(null)
|
||||||
|
const [shouldFocusAfterLogin, setShouldFocusAfterLogin] = useState(false)
|
||||||
|
const [shouldFocusAfterViewChange, setShouldFocusAfterViewChange] = useState(false)
|
||||||
|
|
||||||
|
const setExpandedItem = useCallback((id: string | null) => {
|
||||||
|
setExpandedItemId(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const requestFocusAfterLogin = useCallback(() => {
|
||||||
|
setShouldFocusAfterLogin(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const requestFocusAfterViewChange = useCallback(() => {
|
||||||
|
setShouldFocusAfterViewChange(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldFocusAfterLogin && focusAfterLoginRef.current) {
|
||||||
|
focusAfterLoginRef.current.focus()
|
||||||
|
setShouldFocusAfterLogin(false)
|
||||||
|
}
|
||||||
|
}, [shouldFocusAfterLogin])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldFocusAfterViewChange && focusAfterViewChangeRef.current) {
|
||||||
|
focusAfterViewChangeRef.current.focus()
|
||||||
|
setShouldFocusAfterViewChange(false)
|
||||||
|
}
|
||||||
|
}, [shouldFocusAfterViewChange])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && expandedItemId) {
|
||||||
|
setExpandedItemId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [expandedItemId])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccessibilityContext.Provider
|
||||||
|
value={{
|
||||||
|
expandedItemId,
|
||||||
|
setExpandedItem,
|
||||||
|
requestFocusAfterLogin,
|
||||||
|
focusAfterLoginRef,
|
||||||
|
focusAfterViewChangeRef,
|
||||||
|
requestFocusAfterViewChange,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AccessibilityContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAccessibility() {
|
||||||
|
const context = useContext(AccessibilityContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAccessibility must be used within AccessibilityProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
type Breakpoint = 'mobile' | 'tablet' | 'desktop'
|
||||||
|
|
||||||
|
interface BreakpointState {
|
||||||
|
breakpoint: Breakpoint
|
||||||
|
isMobile: boolean
|
||||||
|
isTablet: boolean
|
||||||
|
isDesktop: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBreakpoint(): BreakpointState {
|
||||||
|
const [state, setState] = useState<BreakpointState>(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return { breakpoint: 'desktop', isMobile: false, isTablet: false, isDesktop: true }
|
||||||
|
}
|
||||||
|
const width = window.innerWidth
|
||||||
|
if (width < 768) {
|
||||||
|
return { breakpoint: 'mobile', isMobile: true, isTablet: false, isDesktop: false }
|
||||||
|
}
|
||||||
|
if (width < 1024) {
|
||||||
|
return { breakpoint: 'tablet', isMobile: false, isTablet: true, isDesktop: false }
|
||||||
|
}
|
||||||
|
return { breakpoint: 'desktop', isMobile: false, isTablet: false, isDesktop: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
const width = window.innerWidth
|
||||||
|
let breakpoint: Breakpoint
|
||||||
|
let isMobile: boolean
|
||||||
|
let isTablet: boolean
|
||||||
|
let isDesktop: boolean
|
||||||
|
|
||||||
|
if (width < 768) {
|
||||||
|
breakpoint = 'mobile'
|
||||||
|
isMobile = true
|
||||||
|
isTablet = false
|
||||||
|
isDesktop = false
|
||||||
|
} else if (width < 1024) {
|
||||||
|
breakpoint = 'tablet'
|
||||||
|
isMobile = false
|
||||||
|
isTablet = true
|
||||||
|
isDesktop = false
|
||||||
|
} else {
|
||||||
|
breakpoint = 'desktop'
|
||||||
|
isMobile = false
|
||||||
|
isTablet = false
|
||||||
|
isDesktop = true
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({ breakpoint, isMobile, isTablet, isDesktop })
|
||||||
|
}
|
||||||
|
|
||||||
|
handleResize()
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
return () => window.removeEventListener('resize', handleResize)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
/* Original design system tokens (for boot/ECG phases) */
|
||||||
--bg: #FFFFFF;
|
--bg: #FFFFFF;
|
||||||
--text: #334155;
|
--text: #334155;
|
||||||
--heading: #0F172A;
|
--heading: #0F172A;
|
||||||
@@ -20,6 +21,28 @@
|
|||||||
--radius: 16px;
|
--radius: 16px;
|
||||||
--font-primary: 'Plus Jakarta Sans', system-ui, sans-serif;
|
--font-primary: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||||
--font-secondary: 'Inter Tight', system-ui, sans-serif;
|
--font-secondary: 'Inter Tight', system-ui, sans-serif;
|
||||||
|
|
||||||
|
/* PMR-specific tokens */
|
||||||
|
--pmr-content: #F5F7FA;
|
||||||
|
--pmr-card: #FFFFFF;
|
||||||
|
--pmr-sidebar: #1E293B;
|
||||||
|
--pmr-banner: #334155;
|
||||||
|
--pmr-nhs-blue: #005EB8;
|
||||||
|
--pmr-green: #22C55E;
|
||||||
|
--pmr-amber: #F59E0B;
|
||||||
|
--pmr-red: #EF4444;
|
||||||
|
--pmr-text-primary: #111827;
|
||||||
|
--pmr-text-secondary: #6B7280;
|
||||||
|
--pmr-border: #E5E7EB;
|
||||||
|
--pmr-border-dark: #D1D5DB;
|
||||||
|
--pmr-selected: #EFF6FF;
|
||||||
|
--pmr-alert-bg: #FEF3C7;
|
||||||
|
--pmr-alert-border: #F59E0B;
|
||||||
|
--pmr-alert-text: #92400E;
|
||||||
|
--pmr-radius: 4px;
|
||||||
|
--pmr-radius-login: 12px;
|
||||||
|
--font-inter: 'Inter', system-ui, sans-serif;
|
||||||
|
--font-geist-mono: 'Geist Mono', 'Fira Code', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -46,6 +69,17 @@ body {
|
|||||||
.font-mono {
|
.font-mono {
|
||||||
font-family: 'Fira Code', monospace;
|
font-family: 'Fira Code', monospace;
|
||||||
}
|
}
|
||||||
|
.font-inter {
|
||||||
|
font-family: var(--font-inter);
|
||||||
|
}
|
||||||
|
.font-geist-mono {
|
||||||
|
font-family: var(--font-geist-mono);
|
||||||
|
}
|
||||||
|
.pmr-theme {
|
||||||
|
background-color: var(--pmr-content);
|
||||||
|
color: var(--pmr-text-primary);
|
||||||
|
font-family: var(--font-inter);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
|
|||||||
+14
-1
@@ -37,10 +37,21 @@ export default {
|
|||||||
sidebar: '#1E293B',
|
sidebar: '#1E293B',
|
||||||
banner: '#334155',
|
banner: '#334155',
|
||||||
content: '#F5F7FA',
|
content: '#F5F7FA',
|
||||||
|
card: '#FFFFFF',
|
||||||
nhsblue: '#005EB8',
|
nhsblue: '#005EB8',
|
||||||
green: '#22C55E',
|
green: '#22C55E',
|
||||||
amber: '#F59E0B',
|
amber: '#F59E0B',
|
||||||
red: '#EF4444',
|
red: '#EF4444',
|
||||||
|
'text-primary': '#111827',
|
||||||
|
'text-secondary': '#6B7280',
|
||||||
|
'text-on-dark': '#FFFFFF',
|
||||||
|
'text-on-dark-secondary': '#94A3B8',
|
||||||
|
'border': '#E5E7EB',
|
||||||
|
'border-dark': '#D1D5DB',
|
||||||
|
'selected-row': '#EFF6FF',
|
||||||
|
'alert-bg': '#FEF3C7',
|
||||||
|
'alert-border': '#F59E0B',
|
||||||
|
'alert-text': '#92400E',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
@@ -54,9 +65,11 @@ export default {
|
|||||||
'sm': '0 1px 3px rgba(0,0,0,0.06)',
|
'sm': '0 1px 3px rgba(0,0,0,0.06)',
|
||||||
'md': '0 4px 12px rgba(0,0,0,0.08)',
|
'md': '0 4px 12px rgba(0,0,0,0.08)',
|
||||||
'lg': '0 8px 24px rgba(0,0,0,0.1)',
|
'lg': '0 8px 24px rgba(0,0,0,0.1)',
|
||||||
|
'pmr': '0 1px 2px rgba(0,0,0,0.03)',
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
'card': '16px',
|
'card': '4px',
|
||||||
|
'login': '12px',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user