Clinic — Visit Notes Editor (SOAP)
A SOAP-format visit-notes editor with quick-insert template chips for each section, a debounced autosave indicator, live word counts, and a sign-and-lock flow that freezes the note and stamps the signing clinician.
MCP
コード
:root {
--teal: #129c93;
--teal-d: #0c7a73;
--teal-700: #0a655f;
--teal-50: #e7f5f3;
--coral: #ff7a66;
--coral-soft: #ffe6df;
--ink: #16322f;
--ink-2: #3a534f;
--muted: #6b827e;
--bg: #f1f7f6;
--white: #ffffff;
--line: rgba(16, 50, 47, 0.1);
--line-2: rgba(16, 50, 47, 0.18);
--ok: #2f9e6f;
--warn: #d98a2b;
--pending: #6b7280;
--danger: #d4503e;
--font: "Inter", system-ui, -apple-system, sans-serif;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--shadow-1: 0 1px 2px rgba(16, 50, 47, 0.05), 0 4px 14px rgba(16, 50, 47, 0.06);
--shadow-2: 0 16px 40px rgba(12, 122, 115, 0.16);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font);
background: var(--bg);
color: var(--ink);
-webkit-font-smoothing: antialiased;
line-height: 1.5;
padding-bottom: 84px;
}
/* ── App bar ── */
.appbar {
background: var(--white);
border-bottom: 1px solid var(--line);
padding: 14px 28px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 20;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: var(--ink);
}
.brand-mark {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 10px;
background: linear-gradient(150deg, var(--teal), var(--teal-d));
color: #fff;
font-size: 1.1rem;
font-weight: 700;
}
.brand-name {
font-size: 1.1rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.brand-name em {
font-style: normal;
color: var(--teal-d);
}
.appbar-actions {
display: flex;
align-items: center;
gap: 14px;
}
/* ── Save state indicator ── */
.save-state {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 0.82rem;
font-weight: 600;
color: var(--muted);
}
.ss-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 3px rgba(47, 158, 111, 0.18);
transition: background 0.2s, box-shadow 0.2s;
}
.save-state.is-saving .ss-dot {
background: var(--warn);
box-shadow: 0 0 0 3px rgba(217, 138, 43, 0.18);
}
.save-state.is-dirty .ss-dot {
background: var(--coral);
box-shadow: 0 0 0 3px rgba(255, 122, 102, 0.2);
}
.save-state.is-saving .ss-text {
color: var(--warn);
}
.save-state.is-dirty .ss-text {
color: var(--coral);
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: var(--teal-d);
color: #fff;
font-weight: 700;
font-size: 0.82rem;
cursor: pointer;
}
/* ── Layout ── */
.editor {
max-width: 920px;
margin: 0 auto;
padding: 26px 28px 40px;
display: flex;
flex-direction: column;
gap: 20px;
}
/* ── Context bar ── */
.context {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 18px 22px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
box-shadow: var(--shadow-1);
}
.ctx-block {
display: flex;
flex-direction: column;
gap: 2px;
}
.ctx-block + .ctx-block {
border-left: 1px solid var(--line);
padding-left: 18px;
}
.ctx-label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--teal-d);
}
.ctx-value {
font-size: 1rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.ctx-sub {
color: var(--muted);
font-weight: 600;
}
.ctx-meta {
font-size: 0.82rem;
color: var(--muted);
}
/* ── SOAP cards ── */
.soap {
display: flex;
flex-direction: column;
gap: 16px;
}
.note-card {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 18px 20px;
transition: border-color 0.2s, background 0.2s;
}
.note-card.is-locked {
background: #fafcfb;
border-color: var(--line-2);
}
.nc-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.nc-letter {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 10px;
background: var(--teal-50);
color: var(--teal-d);
font-weight: 800;
font-size: 1.05rem;
}
.nc-title {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.01em;
}
/* ── Template chips ── */
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.chip {
border: 1px solid var(--line-2);
background: var(--white);
color: var(--ink-2);
border-radius: 999px;
padding: 6px 13px;
font: inherit;
font-weight: 600;
font-size: 0.8rem;
cursor: pointer;
transition: background 0.14s, border-color 0.14s, color 0.14s, transform 0.12s;
}
.chip::before {
content: "+ ";
color: var(--teal);
font-weight: 800;
}
.chip:hover {
background: var(--teal-50);
border-color: var(--teal);
color: var(--teal-d);
}
.chip:active {
transform: translateY(1px);
}
/* ── Textareas ── */
.note-area {
width: 100%;
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 12px 14px;
font: inherit;
font-size: 0.92rem;
color: var(--ink);
background: var(--bg);
resize: vertical;
min-height: 96px;
line-height: 1.55;
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
.note-area::placeholder {
color: var(--muted);
}
.note-area:focus {
outline: none;
border-color: var(--teal);
background: var(--white);
box-shadow: 0 0 0 3px rgba(18, 156, 147, 0.14);
}
.note-area[readonly] {
background: #fafcfb;
color: var(--ink-2);
cursor: default;
resize: none;
}
/* ── Locked / signed state ── */
.note-card.is-locked .chip {
opacity: 0.4;
pointer-events: none;
}
.signed-line {
display: flex;
align-items: center;
gap: 8px;
background: rgba(47, 158, 111, 0.1);
border: 1px solid rgba(47, 158, 111, 0.3);
border-radius: var(--r-md);
padding: 14px 18px;
font-size: 0.9rem;
font-weight: 600;
color: var(--ok);
}
.signed-line::before {
content: "🔒";
font-size: 1rem;
}
/* ── Footer action bar ── */
.actionbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: var(--white);
border-top: 1px solid var(--line);
padding: 14px 28px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
z-index: 20;
box-shadow: 0 -4px 16px rgba(16, 50, 47, 0.05);
}
.word-count {
font-size: 0.84rem;
font-weight: 600;
color: var(--muted);
}
.ab-actions {
display: flex;
gap: 10px;
}
/* ── Buttons ── */
.btn {
border: none;
border-radius: 11px;
padding: 11px 18px;
font: inherit;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: transform 0.12s, background 0.15s, opacity 0.15s;
}
.btn:active {
transform: translateY(1px);
}
.btn.primary {
background: var(--coral);
color: #fff;
}
.btn.primary:hover {
background: #ff8e7c;
}
.btn.ghost {
background: var(--teal-50);
color: var(--teal-d);
}
.btn.ghost:hover {
background: #d9efec;
}
.btn:disabled {
opacity: 0.5;
cursor: default;
transform: none;
}
/* ── Toast ── */
.toast {
position: fixed;
left: 50%;
bottom: 88px;
transform: translateX(-50%);
background: var(--ink);
color: #fff;
padding: 13px 20px;
border-radius: 12px;
font-size: 0.9rem;
font-weight: 500;
box-shadow: var(--shadow-2);
z-index: 50;
max-width: 90vw;
}
@media (max-width: 720px) {
.context {
grid-template-columns: 1fr;
gap: 14px;
}
.ctx-block + .ctx-block {
border-left: none;
padding-left: 0;
border-top: 1px solid var(--line);
padding-top: 14px;
}
.actionbar {
flex-direction: column;
align-items: stretch;
}
.ab-actions {
justify-content: flex-end;
}
}
/* Visibility guard: honor the [hidden] attribute over base display */
.signed-line[hidden] {
display: none;
}// ── Elements ─────────────────────────────────────────────────────────────────
const soap = document.getElementById("soap");
const areas = Array.from(document.querySelectorAll(".note-area"));
const saveState = document.getElementById("saveState");
const saveText = saveState.querySelector(".ss-text");
const wordCount = document.getElementById("wordCount");
const signBtn = document.getElementById("signBtn");
const saveDraftBtn = document.getElementById("saveDraftBtn");
const signedLine = document.getElementById("signedLine");
const toast = document.getElementById("toast");
let locked = false;
// Illustrative: a fixed timestamp is used instead of Date.now().
const SIGNED_AT = "8 June 2026, 10:52 AM";
const CLINICIAN = "Dr. Ravi Patel";
// ── Toast ────────────────────────────────────────────────────────────────────
function showToast(msg) {
toast.textContent = msg;
toast.hidden = false;
clearTimeout(showToast._t);
showToast._t = setTimeout(() => (toast.hidden = true), 2600);
}
// ── Word count across all four sections ──────────────────────────────────────
function countWords() {
const total = areas.reduce((sum, a) => {
const words = a.value.trim().split(/\s+/).filter(Boolean);
return sum + words.length;
}, 0);
wordCount.textContent = `${total} word${total === 1 ? "" : "s"}`;
}
// ── Autosave indicator state machine ─────────────────────────────────────────
function setSaveState(state) {
saveState.classList.remove("is-saving", "is-dirty");
if (state === "saving") {
saveState.classList.add("is-saving");
saveText.textContent = "Saving…";
} else if (state === "dirty") {
saveState.classList.add("is-dirty");
saveText.textContent = "Unsaved changes";
} else {
saveText.textContent = "Saved";
}
}
let saveTimer;
function scheduleSave() {
if (locked) return;
setSaveState("dirty");
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
setSaveState("saving");
setTimeout(() => setSaveState("saved"), 600);
}, 900);
}
// ── Typing → word count + autosave ───────────────────────────────────────────
areas.forEach((area) => {
area.addEventListener("input", () => {
countWords();
scheduleSave();
});
});
// ── Template chip insertion ──────────────────────────────────────────────────
soap.addEventListener("click", (e) => {
const chip = e.target.closest(".chip");
if (!chip || locked) return;
const card = chip.closest(".note-card");
const area = card.querySelector(".note-area");
const snippet = chip.dataset.insert;
if (area.value && !area.value.endsWith("\n") && !area.value.endsWith(" ")) {
area.value += "\n";
}
area.value += snippet;
area.focus();
area.setSelectionRange(area.value.length, area.value.length);
countWords();
scheduleSave();
});
// ── Save draft ───────────────────────────────────────────────────────────────
saveDraftBtn.addEventListener("click", () => {
if (locked) return;
clearTimeout(saveTimer);
setSaveState("saving");
setTimeout(() => {
setSaveState("saved");
showToast("Draft saved.");
}, 600);
});
// ── Sign & lock ──────────────────────────────────────────────────────────────
signBtn.addEventListener("click", () => {
if (locked) return;
locked = true;
clearTimeout(saveTimer);
areas.forEach((a) => a.setAttribute("readonly", ""));
document.querySelectorAll(".note-card").forEach((c) => c.classList.add("is-locked"));
setSaveState("saved");
saveText.textContent = "Signed";
signedLine.textContent = `Signed by ${CLINICIAN} · ${SIGNED_AT}`;
signedLine.hidden = false;
signBtn.disabled = true;
signBtn.textContent = "Signed ✓";
saveDraftBtn.disabled = true;
showToast("Note signed and locked.");
});
// ── Init ─────────────────────────────────────────────────────────────────────
countWords();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
/>
<link rel="stylesheet" href="style.css" />
<title>Visit Notes · Northbridge Clinic</title>
</head>
<body>
<header class="appbar">
<a class="brand" href="#">
<span class="brand-mark" aria-hidden="true">✚</span>
<span class="brand-name">Northbridge <em>Health</em></span>
</a>
<div class="appbar-actions">
<span class="save-state" id="saveState" role="status" aria-live="polite">
<span class="ss-dot" aria-hidden="true"></span>
<span class="ss-text">Saved</span>
</span>
<button class="avatar" aria-label="Account">RP</button>
</div>
</header>
<main class="editor" id="editor">
<section class="context" aria-label="Encounter context">
<div class="ctx-block">
<p class="ctx-label">Patient</p>
<p class="ctx-value">Amara Mensah <span class="ctx-sub">· 47 F</span></p>
<p class="ctx-meta">MRN 0042-8817</p>
</div>
<div class="ctx-block">
<p class="ctx-label">Encounter</p>
<p class="ctx-value">8 June 2026 · 10:30 AM</p>
<p class="ctx-meta">Follow-up visit</p>
</div>
<div class="ctx-block">
<p class="ctx-label">Clinician</p>
<p class="ctx-value">Dr. Ravi Patel</p>
<p class="ctx-meta">Primary care</p>
</div>
</section>
<div class="soap" id="soap">
<section class="note-card" data-section="S">
<div class="nc-head">
<span class="nc-letter">S</span>
<h2 class="nc-title">Subjective</h2>
</div>
<div class="chips" aria-label="Subjective templates">
<button class="chip" data-insert="Chief complaint: ">Chief complaint</button>
<button class="chip" data-insert="HPI: Patient reports ">HPI</button>
<button class="chip" data-insert="ROS: Negative except as noted above. ">ROS normal</button>
</div>
<textarea
class="note-area"
id="area-S"
rows="4"
aria-label="Subjective notes"
placeholder="Patient-reported symptoms, history, and concerns…"
></textarea>
</section>
<section class="note-card" data-section="O">
<div class="nc-head">
<span class="nc-letter">O</span>
<h2 class="nc-title">Objective</h2>
</div>
<div class="chips" aria-label="Objective templates">
<button class="chip" data-insert="Vitals stable, within normal limits. ">Vitals stable</button>
<button class="chip" data-insert="Physical exam unremarkable. ">Exam normal</button>
</div>
<textarea
class="note-area"
id="area-O"
rows="4"
aria-label="Objective notes"
placeholder="Vitals, exam findings, and measurable observations…"
></textarea>
</section>
<section class="note-card" data-section="A">
<div class="nc-head">
<span class="nc-letter">A</span>
<h2 class="nc-title">Assessment</h2>
</div>
<div class="chips" aria-label="Assessment templates">
<button class="chip" data-insert="Condition stable. ">Stable</button>
<button class="chip" data-insert="Improving on current regimen. ">Improving</button>
</div>
<textarea
class="note-area"
id="area-A"
rows="4"
aria-label="Assessment notes"
placeholder="Diagnosis and clinical impression…"
></textarea>
</section>
<section class="note-card" data-section="P">
<div class="nc-head">
<span class="nc-letter">P</span>
<h2 class="nc-title">Plan</h2>
</div>
<div class="chips" aria-label="Plan templates">
<button class="chip" data-insert="Continue current medications. ">Continue meds</button>
<button class="chip" data-insert="Follow up in 2 weeks. ">Follow up 2 weeks</button>
<button class="chip" data-insert="Labs ordered. ">Labs ordered</button>
</div>
<textarea
class="note-area"
id="area-P"
rows="4"
aria-label="Plan notes"
placeholder="Treatment, prescriptions, referrals, and follow-up…"
></textarea>
</section>
</div>
<p class="signed-line" id="signedLine" hidden></p>
</main>
<footer class="actionbar">
<span class="word-count" id="wordCount">0 words</span>
<div class="ab-actions">
<button class="btn ghost" id="saveDraftBtn">Save draft</button>
<button class="btn primary" id="signBtn">Sign & lock</button>
</div>
</footer>
<div class="toast" id="toast" hidden role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Visit Notes Editor (SOAP)
The clinician-side documentation surface for a single encounter. A context bar pins the patient, encounter, and signing clinician up top, then four cards — Subjective, Objective, Assessment, Plan — each pair a free-text area with a row of quick template chips that drop boilerplate into the note. A status strip tracks a debounced autosave indicator and a live total word count, while the footer action bar lets you save a draft or Sign & lock — which freezes every section, disables editing, and stamps the note with the signing clinician. All interactions are vanilla JS.
Illustrative UI only — not intended for real medical use.