init: company-haness 설계

This commit is contained in:
DongHyeonka
2026-07-23 17:49:00 +09:00
parent 57d1bab894
commit f668d6a158
962 changed files with 98989 additions and 1 deletions
@@ -0,0 +1,19 @@
direction-cycle-id: hyeonworks-vnext-direction-cycle-1
representative-screen:
id: dual-entry-shared-lab
kind: first-entry
description: "두 출발점과 하나의 Lost Update Lab, 짧은 Atlas path, 공통 학습 루프, guided-scenario 경계를 한 화면에서 이해하는 첫 화면"
direction:
id: field-manual
design-question: "굵은 단계·규칙·체크포인트의 현장 매뉴얼처럼 구성하면 두 진입과 학습 행동을 가장 실행 가능하게 만드는가?"
layout-topology: "모듈형 poster stack; 상단 선언, 2-up entry panels, numbered common protocol, compact scenario preview"
navigation-model: "명시적 numbered checkpoints; entry 후 공통 Protocol 01로 합류"
typography-voice: "condensed display labels + warm humanist sans instructions + mono commands"
imagery-strategy: "번호판·규칙 블록·값 토큰·인과 화살표를 사용한 field diagram"
motion-model: "checkpoint 완료 시 stamp 전환만 사용; reduced motion과 동일 정보 구조"
dominant-primitives: [protocol-panel, numbered-checkpoint, rule-block]
exclusive-primitives: [field-stamp, procedure-strip, transfer-ticket]
forbidden-primitives: [serif-folio, signal-oscilloscope, dashboard-metric-grid]
sibling-isolation:
forbidden-direction-ids: [ledger-studio, signal-trace]
note: "형제 방향의 코드·미리보기·reference cluster를 읽거나 차용하지 않는다."
@@ -0,0 +1,19 @@
direction-cycle-id: hyeonworks-vnext-direction-cycle-1
representative-screen:
id: dual-entry-shared-lab
kind: first-entry
description: "두 출발점과 하나의 Lost Update Lab, 짧은 Atlas path, 공통 학습 루프, guided-scenario 경계를 한 화면에서 이해하는 첫 화면"
direction:
id: ledger-studio
design-question: "기술 인과를 전문 저널의 검증 가능한 실험 기록처럼 읽게 하면 깊이와 신뢰를 전달하는가?"
layout-topology: "비대칭 editorial spread; 왼쪽 큰 논제와 두 entry, 오른쪽 shared lab ledger 및 하단 causal path"
navigation-model: "두 entry anchor에서 공통 Lab Brief로 합류하는 세로 읽기; 명시적 skip link"
typography-voice: "humanist serif display + neutral grotesk body + mono evidence annotations"
imagery-strategy: "값 변화가 적힌 ledger lines, margin note, read/compute/write의 인쇄 도식"
motion-model: "합류선을 짧게 그리며 evidence row만 순차 강조; reduced motion에서는 정적 표시"
dominant-primitives: [entry-column, causal-ledger, evidence-margin]
exclusive-primitives: [merge-rule, ledger-folio, annotated-value]
forbidden-primitives: [node-canvas, terminal-window, glass-card-grid]
sibling-isolation:
forbidden-direction-ids: [signal-trace, field-manual]
note: "형제 방향의 코드·미리보기·reference cluster를 읽거나 차용하지 않는다."
@@ -0,0 +1,19 @@
direction-cycle-id: hyeonworks-vnext-direction-cycle-1
representative-screen:
id: dual-entry-shared-lab
kind: first-entry
description: "두 출발점과 하나의 Lost Update Lab, 짧은 Atlas path, 공통 학습 루프, guided-scenario 경계를 한 화면에서 이해하는 첫 화면"
direction:
id: signal-trace
design-question: "두 세션의 신호와 값 흐름을 계측 rail처럼 추적하면 Lost Update의 시간적 인과를 더 빨리 이해하는가?"
layout-topology: "상단 dual input bands가 중앙 merge bus로 수렴하고 아래 full-width transaction trace로 이어지는 rail topology"
navigation-model: "entry band 선택 뒤 공통 trace focus; 단계는 위아래 rail과 native next controls로 이동"
typography-voice: "precise sans + tabular numeric + compact uppercase signal labels"
imagery-strategy: "solid signal rails, stamped state nodes, striped conflict region; 색 외 session labels 반복"
motion-model: "실행 단계에서 현재 rail segment만 pulse; reduced motion은 굵기·패턴 전환"
dominant-primitives: [entry-band, merge-bus, trace-rail]
exclusive-primitives: [signal-stamp, conflict-hatch, value-probe]
forbidden-primitives: [editorial-marginalia, detective-file, orbit-map]
sibling-isolation:
forbidden-direction-ids: [ledger-studio, field-manual]
note: "형제 방향의 코드·미리보기·reference cluster를 읽거나 차용하지 않는다."
@@ -0,0 +1,171 @@
identity:
producer-run-id: hyeonworks-vnext-v1-direction-divergence-manual-20260718T1145Z
context-package-sha256: eedcf6c839f69f3df7a30e7ea18b52ed5be3136c2699a2f7fe183eb27e877b30
artifact-kind: visual-direction-concept
artifact-version: 1
direction-id: field-manual
representative-screen-id: dual-entry-shared-lab
visual-thesis: >-
서로 다른 두 단서를 독립 콘텐츠처럼 꾸미지 않고, 한 장의 현장 매뉴얼에서 선택 가능한
Entry 01A·01B와 하나의 Protocol 01로 편성한다. 굵은 번호는 읽을 순서가 아니라 지금
실행할 행동을 지시하고, 모든 큰 선언 바로 뒤에는 선택·예측·실행할 control이나 검증 가능한
값 변화가 따라온다. 제한된 yellow·green·orange 표식은 상태를 장식하지 않고 활성 모듈,
절차 header, 증상 증거만 구분한다. 결과적으로 화면은 marketing poster가 아니라
TX-LOST-UPDATE-01을 Predict → Observe → Compare → Explain → Transfer로 수행하는
교육용 quick-reference manual처럼 작동한다.
concept-invariants:
- "Entry 01A와 Entry 01B는 모두 동일한 TX-LOST-UPDATE-01 및 Protocol 01을 가리킨다."
- "entry mode는 orientation metadata일 뿐, 합류 후 학습 단계·상태·콘텐츠를 분기하지 않는다."
- "활성 딥다이브는 Module 01 하나로 정직하게 표시하고 roadmap은 준비 중으로 구분한다."
- "증상 경로는 실제 장애 진단이 아니라 guided scenario에서 후보 메커니즘을 검증하는 교육 범위다."
- "큰 번호 뒤에는 즉시 실행 가능한 control, 절차, 또는 판독 가능한 evidence가 온다."
reference-clusters:
- id: quick-reference-procedure
reference: aircraft quick reference handbook
useful-signals:
- "행동 순서를 굵은 단계 번호와 짧은 imperative로 분리한다."
- "경계 조건과 주의 사항을 본문이 아닌 별도 rule block으로 둔다."
- "필요한 순간에 빠르게 재진입할 수 있는 선형 scan path를 만든다."
translation:
layout: "두 entry 선택 다음에 0105 공통 protocol을 끊김 없는 procedure strip으로 배치한다."
interaction: "각 단계는 동사 label과 한 개의 주 행동을 가지며 완료 전후에도 순서가 유지된다."
boundary: "실제 운항·안전 매뉴얼의 권위, 위험 등급, emergency 표식은 차용하지 않는다."
- id: controlled-module-system
reference: industrial field service manual
useful-signals:
- "module 번호, revision, 적용 범위를 좁은 metadata rail에 모은다."
- "한정된 spot color와 두꺼운 divider로 section ownership을 드러낸다."
- "rule, checkpoint, result를 서로 다른 블록 형태로 구분한다."
translation:
layout: "MODULE 01 / TX-LOST-UPDATE-01 / GUIDED SCENARIO를 상단 identification band에 묶는다."
interaction: "선택 결과는 가짜 승인 stamp가 아니라 현재 entry와 공통 합류점을 텍스트로 재확인한다."
boundary: "실제 incident 대응, 원격 DB 점검, 현장 인증 완료를 암시하는 표현은 사용하지 않는다."
- id: instructional-hierarchy
reference: Swiss instructional poster
useful-signals:
- "비대칭 대형 숫자와 고정 grid로 주행 방향을 한눈에 만든다."
- "짧은 문장, 넓은 여백, 강한 alignment로 정보 밀도를 통제한다."
- "색보다 크기·위치·선으로 위계와 합류 관계를 설명한다."
translation:
layout: "01A·01B는 동등한 2-up panel, 합류 Protocol 01은 두 panel 전체 폭을 점유한다."
interaction: "focus ring, 선택 marker, 라벨을 함께 사용해 색 없이도 현재 진입을 판독하게 한다."
boundary: "큰 headline만 남는 전시 poster나 제품 가치 선언형 hero로 축약하지 않는다."
layout-grammar:
topology: modular-poster-stack
desktop:
grid: "12-column fixed alignment grid; outer margin 4872px; 24px gutter"
sequence:
- "identification band: MODULE 01, scenario id, guided-scenario boundary"
- "statement band: 두 입구가 하나의 Lab으로 합류한다는 한 문장"
- "entry field: Entry 01A와 01B를 6-column씩 동등하게 배치"
- "merge rule: 두 entry 아래를 가로지르는 명시적 ONE SHARED LAB divider"
- "protocol field: PredictTransfer 다섯 checkpoint를 전체 폭 procedure strip으로 배치"
- "evidence field: 100에서 두 session read/compute/write를 거쳐 70이 되는 compact preview"
- "scope and roadmap: 교육 범위 rule block과 Module 02+ 준비 중 표기"
tablet:
rule: "768px에서 entry는 2-up을 유지하되 protocol은 3+2 row로 감싸고 DOM 순서는 0105를 유지한다."
mobile:
rule: "360390px에서 identification → 01A → 01B → merge → protocol 0105 → evidence → scope 순으로 1-column 재배치한다."
guardrails:
- "가로 timeline이나 드래그를 요구하지 않는다."
- "숫자는 장식 배경이 아닌 section 시작점으로 축소한다."
- "read/compute/write 비교는 같은 필드명을 반복하는 선형 목록으로 제공한다."
alignment-rule: "번호, 제목, control의 왼쪽 시작선을 공유하고 설명 문단은 그 선을 침범하지 않는다."
spacing-rule: "8px base; 한 절차 내부 8/16px, checkpoint 사이 24px, module 사이 48/72px."
interaction-grammar:
entry-selection:
control: "native button 두 개: ‘개념을 알고 있어요’와 ‘증상만 알고 있어요’"
feedback: "선택 marker + ENTRY SELECTED 텍스트 + Protocol 01 합류 안내를 동시에 갱신한다."
shared-core-rule: "어느 버튼을 선택해도 동일 scenario id와 동일 다섯 단계 control을 사용한다."
checkpoint-loop:
- {step: "01 Predict", action: "실행 전 최종값 또는 상태 변화를 선택", evidence: "예측값을 고정해 이후 비교 기준으로 보존"}
- {step: "02 Observe", action: "read/compute/write를 순서대로 실행", evidence: "현재 session·operation·value를 텍스트로 공지"}
- {step: "03 Compare", action: "예측과 실제 70을 나란히 확인", evidence: "일치 여부와 차이를 숫자·문장으로 병기"}
- {step: "04 Explain", action: "동일 초기값 read와 overwrite 인과를 설명", evidence: "필수 인과 checkpoint를 명시"}
- {step: "05 Transfer", action: "새 사례에서 같은 메커니즘 후보를 판별", evidence: "전이 판단과 근거를 함께 제출"}
focus:
rule: "Tab 순서는 시각 순서와 같고, focus-visible은 3px 이중 outline과 offset으로 배경색과 독립시킨다."
keyboard: "Tab, Enter, Space만으로 entry 선택·단서 공개·예측·실행·설명 제출이 가능하다."
state-announcement: "실험 단계 변화와 값 갱신은 aria-live로 짧게 알리되 focus를 자동 이동하지 않는다."
motion: "checkpoint 전환은 160ms 이하의 opacity/position 변화만 허용하고 prefers-reduced-motion에서는 즉시 교체한다."
prohibited:
- "완료를 증명하지 않는 장식 stamp"
- "hover 전용 설명"
- "drag 전용 timeline"
- "entry별 progress 또는 protocol 분기"
type-grammar:
display:
role: "module 번호, entry 번호, protocol 단계"
voice: "condensed sans, uppercase 영문 label, tabular numeral"
scale: "desktop 4888px; mobile 3652px; line-height 0.91.0"
constraint: "설명 문장, 교육 범위, roadmap에는 대형 condensed display를 사용하지 않는다."
instruction:
role: "한국어 제목, 행동 지시, 설명, boundary copy"
voice: "warm humanist sans"
scale: "body 1618px / 1.551.7; action label 1824px / 1.25"
constraint: "한 checkpoint의 핵심 지시는 한 문장, 보조 설명은 두 문장을 넘기지 않는다."
command:
role: "TX-LOST-UPDATE-01, session, read/compute/write, 100→150/70 값"
voice: "monospace with tabular numerals"
scale: "1316px / 1.45"
constraint: "코드·식별자·상태값에만 사용하며 terminal 미학의 전체 화면 mono는 금지한다."
labeling:
rule: "한국어 행동이 먼저 오고 필요한 경우 원어를 병기한다: ‘예측 Predict."
case: "영문 metadata만 uppercase; 한국어 본문과 control label에는 강제 대문자 효과를 쓰지 않는다."
primitive-grammar:
protocol-panel:
anatomy: "step number + imperative + one primary control + evidence line"
rule: "각 panel은 하나의 행동만 소유하고 다음 panel과 2px divider로 연결한다."
numbered-checkpoint:
anatomy: "two-digit index + verb label + state marker"
rule: "번호는 module과 protocol 단계에만 사용하며 설명 카드에는 붙이지 않는다."
rule-block:
anatomy: "RULE label + 범위 문장 + 허용/비허용 경계"
rule: "guided scenario와 실제 장애 진단의 경계를 반드시 한 번 이상 명시한다."
field-stamp:
anatomy: "outlined lozenge + factual state text"
rule: "GUIDED SCENARIO, ACTIVE MODULE처럼 사실인 범주만 표시하며 승인·완료를 가장하지 않는다."
procedure-strip:
anatomy: "0105 ordered cells + persistent connector + current-step marker"
rule: "색을 제거해도 번호, label, marker 형태로 현재 단계와 순서를 읽을 수 있어야 한다."
transfer-ticket:
anatomy: "new-case prompt + mechanism choice + rationale field"
rule: "Protocol 05에서만 사용하고 결과가 진단 확정이 아니라 학습 전이임을 명시한다."
value-token:
anatomy: "semantic label + numeric value + operation text"
rule: "100, 150, 70을 단독 badge로 쓰지 않고 session과 read/write 맥락을 병기한다."
color-grammar:
canvas: {value: "#f2eedf", use: "따뜻한 종이색 바탕", boundary: "질감 효과나 낡은 문서 연출 금지"}
ink: {value: "#18211d", use: "본문·선·기본 control", boundary: "최소 AA 대비 유지"}
manual-yellow: {value: "#e7bd45", use: "활성 Module 01과 shared checkpoint", boundary: "경고색 또는 전체 배경으로 쓰지 않음"}
instruction-green: {value: "#356c5d", use: "protocol header와 현재 절차 marker", boundary: "성공의 유일한 신호로 쓰지 않음"}
evidence-orange: {value: "#d75b32", use: "증상 entry와 overwrite 결과 70", boundary: "장식 stamp와 일반 강조에 남용하지 않음"}
accessibility-grammar:
- "텍스트·control은 WCAG AA 이상 대비를 유지한다."
- "세션·충돌·정답·현재 단계는 색과 함께 텍스트·아이콘·형태로 구분한다."
- "360/768/1280px에서 가로 스크롤 없이 Transfer까지 도달한다."
- "DOM 읽기 순서는 identification에서 교육 범위까지 시각 순서와 일치한다."
- "reduced-motion에서도 정보·순서·상태 표식이 완전히 동일하다."
anti-patterns:
- marketing hero만 있는 poster
- dashboard metric grid
- serif editorial folio
- neon signal trace 또는 terminal theater
- 가짜 완료·승인 stamp
- 실제 incident 대응 매뉴얼을 암시하는 위험 표식
- 두 entry별 별도 protocol·progress·content
- 색만으로 구분한 단계·session·결과
- 활성 콘텐츠 하나를 큰 atlas나 catalogue로 과장하는 표현
@@ -0,0 +1,43 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Hyeonworks — Field Manual direction</title>
<style>
:root{--paper:#f2ead8;--sheet:#fffaf0;--ink:#1d2a35;--muted:#657078;--rule:#2d3942;--orange:#d75b32;--yellow:#e7bd45;--green:#356c5d;--focus:#1456a0}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink);font-family:"Arial Narrow",Arial,sans-serif;line-height:1.45}a{color:inherit}
.skip{position:absolute;left:1rem;top:-6rem;background:var(--ink);color:white;padding:.75rem 1rem;z-index:20}.skip:focus{top:1rem}:focus-visible{outline:3px solid var(--focus);outline-offset:4px}
.mast{display:grid;grid-template-columns:auto 1fr auto;gap:1rem;align-items:center;padding:1rem clamp(1rem,4vw,4rem);background:var(--ink);color:var(--sheet);text-transform:uppercase;letter-spacing:.09em}.mark{font-weight:900}.mast-line{border-top:1px solid #82909a}.issue{font:700 .68rem ui-monospace,monospace}
main{max-width:1460px;margin:auto;padding:clamp(1rem,3vw,2.8rem)}.atlas{display:flex;flex-wrap:wrap;gap:.4rem;align-items:center;font:700 .7rem ui-monospace,monospace;text-transform:uppercase;letter-spacing:.07em;color:var(--muted)}.atlas span{padding:.35rem .5rem;border:1px solid #9ca098}.atlas b{background:var(--yellow);color:var(--ink);padding:.4rem .55rem}
.cover{display:grid;grid-template-columns:minmax(0,1fr) minmax(340px,.62fr);gap:0;margin-top:1rem;border:2px solid var(--rule);background:var(--sheet)}.cover-copy{padding:clamp(1.5rem,5vw,5rem);position:relative}.kicker{font:900 .72rem ui-monospace,monospace;color:var(--orange);text-transform:uppercase;letter-spacing:.12em}.cover h1{font-size:clamp(3.4rem,8vw,8rem);line-height:.82;letter-spacing:-.075em;text-transform:uppercase;margin:1rem 0 1.6rem;max-width:8ch}.cover-copy p{font-size:clamp(1rem,1.5vw,1.2rem);max-width:54ch}.stamp{display:inline-block;margin-top:1.4rem;padding:.6rem .8rem;border:3px double var(--orange);color:var(--orange);font:900 .72rem ui-monospace,monospace;text-transform:uppercase;transform:rotate(-1deg)}
.cover-index{display:grid;grid-template-rows:auto 1fr auto;border-left:2px solid var(--rule);background:var(--yellow)}.index-head{display:flex;justify-content:space-between;padding:1rem;border-bottom:2px solid var(--rule);font:900 .7rem ui-monospace,monospace;text-transform:uppercase}.big-number{align-self:center;text-align:center;font-size:clamp(8rem,18vw,16rem);font-weight:900;line-height:.75;letter-spacing:-.1em}.index-foot{padding:1rem;border-top:2px solid var(--rule);font:700 .8rem ui-monospace,monospace}
.instruction{display:grid;grid-template-columns:10rem 1fr;margin-top:1.3rem;border:2px solid var(--rule);background:var(--sheet)}.side-title{padding:1rem;background:var(--green);color:white;font:900 .72rem ui-monospace,monospace;letter-spacing:.08em;text-transform:uppercase;writing-mode:vertical-rl;transform:rotate(180deg);text-align:right}.instruction-body{padding:clamp(1.2rem,3vw,2.5rem)}.instruction-body>header{display:flex;justify-content:space-between;gap:2rem;align-items:start;padding-bottom:1rem;border-bottom:2px solid var(--rule)}.instruction h2{font-size:clamp(2rem,4vw,4.4rem);line-height:.9;text-transform:uppercase;letter-spacing:-.05em;margin:0}.instruction-body>header p{max-width:42ch;margin:0;color:var(--muted)}
.starts{display:grid;grid-template-columns:1fr 1fr;margin-top:1.2rem;border:1px solid var(--rule)}.start{display:grid;grid-template-columns:4.5rem 1fr;min-height:250px;text-decoration:none;background:var(--sheet)}.start+.start{border-left:1px solid var(--rule)}.start:hover{background:#f7e9c2}.start-no{display:flex;justify-content:center;align-items:center;background:var(--ink);color:white;font:900 2rem ui-monospace,monospace}.start.symptom .start-no{background:var(--orange)}.start-copy{padding:1.3rem;display:flex;flex-direction:column}.start small{font:900 .68rem ui-monospace,monospace;text-transform:uppercase;color:var(--green)}.start h3{font-size:clamp(1.8rem,3.5vw,3.4rem);line-height:.92;letter-spacing:-.05em;margin:.8rem 0}.start p{max-width:34ch;color:var(--muted)}.start strong{margin-top:auto;font:900 .74rem ui-monospace,monospace;text-transform:uppercase}.start strong:after{content:" →"}
.join{display:grid;grid-template-columns:1fr auto 1fr;gap:.8rem;align-items:center;margin:1rem 0}.join:before,.join:after{content:"";border-top:2px solid var(--rule)}.join b{background:var(--yellow);border:2px solid var(--rule);padding:.65rem .85rem;font:900 .68rem ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em}
.procedure{display:grid;grid-template-columns:repeat(5,1fr);border:2px solid var(--rule);background:var(--sheet)}.phase{min-height:205px;padding:1rem;display:flex;flex-direction:column}.phase+.phase{border-left:2px solid var(--rule)}.phase b{font:900 2.4rem/1 ui-monospace,monospace;color:var(--orange)}.phase h3{font-size:1.15rem;text-transform:uppercase;margin:1.2rem 0 .45rem}.phase p{margin:0;color:var(--muted);font-size:.88rem}.phase:nth-child(2n){background:#e9e5d6}
.scenario{display:grid;grid-template-columns:minmax(270px,.62fr) 1.38fr;margin-top:1.3rem;border:2px solid var(--rule);background:var(--sheet)}.scenario-title{padding:1.4rem;background:var(--ink);color:white}.scenario-title small{font:700 .68rem ui-monospace,monospace;color:#e7bd45}.scenario-title h2{font-size:clamp(2rem,4vw,4rem);line-height:.9;margin:1rem 0}.scenario-title p{color:#cbd2d4}.values{display:flex;gap:.5rem;margin-top:1.2rem}.values span{flex:1;border-top:1px solid #86939a;padding-top:.5rem;font:700 .65rem ui-monospace,monospace;text-transform:uppercase}.values b{display:block;font-size:1.6rem;color:white}.values .alert b{color:#ff8b65}
.schedule{padding:1.4rem}.schedule-head{display:flex;justify-content:space-between;gap:1rem;border-bottom:3px solid var(--rule);padding-bottom:.7rem;font:900 .7rem ui-monospace,monospace;text-transform:uppercase}.event{display:grid;grid-template-columns:3rem 1fr 1fr;min-height:58px;align-items:center;border-bottom:1px solid #a6a69d;font:700 .8rem ui-monospace,monospace}.event>span{padding:.75rem}.event .n{color:var(--muted)}.event .session-b{border-left:1px dashed #96978f}.event.conflict{background:#f5d6c8}.event.conflict .session-b{color:#a6321f;font-weight:900}
.notice{display:grid;grid-template-columns:auto 1fr;gap:1rem;margin-top:1.3rem;padding:1rem;border:2px dashed var(--orange);background:#fff3dd}.notice b{font:900 .7rem ui-monospace,monospace;text-transform:uppercase;color:var(--orange)}.notice p{margin:0;font-size:.87rem}
footer{display:flex;justify-content:space-between;gap:1rem;padding:1.5rem clamp(1rem,3vw,2.8rem);font:700 .7rem ui-monospace,monospace;text-transform:uppercase;color:var(--muted)}
@media(max-width:850px){.cover{grid-template-columns:1fr}.cover-index{grid-template-columns:auto 1fr auto;grid-template-rows:1fr;border-left:0;border-top:2px solid var(--rule)}.index-head{border-bottom:0;border-right:2px solid var(--rule);flex-direction:column}.big-number{font-size:8rem}.index-foot{border-top:0;border-left:2px solid var(--rule)}.instruction{grid-template-columns:3rem 1fr}.instruction-body>header{display:block}.instruction-body>header p{margin-top:1rem}.starts{grid-template-columns:1fr}.start+.start{border-left:0;border-top:1px solid var(--rule)}.procedure{grid-template-columns:1fr}.phase{min-height:auto;display:grid;grid-template-columns:3.5rem 7rem 1fr;gap:.7rem;align-items:start}.phase+.phase{border-left:0;border-top:2px solid var(--rule)}.phase h3{margin:.25rem 0}.scenario{grid-template-columns:1fr}}
@media(max-width:480px){.mast{grid-template-columns:1fr auto}.mast-line{display:none}.cover h1{font-size:2.8rem}.cover-index{grid-template-columns:1fr auto}.index-head{display:none}.big-number{font-size:6.4rem;text-align:left;padding:1rem}.instruction{grid-template-columns:1fr}.side-title{writing-mode:horizontal-tb;transform:none}.start{grid-template-columns:3.5rem 1fr}.phase{grid-template-columns:3rem 1fr}.phase p{grid-column:2}.event{grid-template-columns:2.3rem 1fr}.event .session-b{grid-column:2;border-left:0;padding-top:0}.notice{grid-template-columns:1fr}footer{display:block}}
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*{animation:none!important;transition:none!important}}
</style>
</head>
<body>
<a class="skip" href="#start">시작 절차로 건너뛰기</a>
<header class="mast"><div class="mark">Hyeonworks / Field Manual</div><div class="mast-line"></div><div class="issue">FM001 · Rev A</div></header>
<main>
<nav class="atlas" aria-label="Technology Atlas 현재 위치"><span>Concurrency</span><span>Transaction Isolation</span><b>Lost Update</b></nav>
<section class="cover" aria-labelledby="title"><div class="cover-copy"><div class="kicker">Technical learning protocol · shared core</div><h1 id="title">Trace<br/>the<br/>overwrite.</h1><p>두 요청이 모두 성공했는데 결과는 틀렸습니다. 아는 개념이나 관찰한 증상 중 하나를 들고 들어와, 같은 실험 절차로 덮어쓰기의 인과를 확인하세요.</p><span class="stamp">두 입구 · 하나의 Lost Update Lab</span></div><aside class="cover-index" aria-label="매뉴얼 번호"><div class="index-head"><span>Module</span><span>Active</span></div><div class="big-number">01</div><div class="index-foot">Transaction Isolation<br/>Lost Update</div></aside></section>
<section id="start" class="instruction" aria-labelledby="start-title"><div class="side-title">Instruction A · choose your evidence</div><div class="instruction-body"><header><h2 id="start-title">가지고 있는 단서를 선택하세요.</h2><p>준비 절차만 달라집니다. 선택 직후 두 경로는 동일한 <strong>tx-lost-update-01</strong> 시나리오와 학습 상태에서 합류합니다.</p></header>
<div class="starts"><a class="start" href="#protocol"><span class="start-no">A</span><span class="start-copy"><small>Mechanism entry</small><h3>개념을 알고 있어요</h3><p>격리 수준과 read/write 충돌의 관계를 짧게 정렬한 뒤 실험합니다.</p><strong>개념에서 시작</strong></span></a><a class="start symptom" href="#protocol"><span class="start-no">B</span><span class="start-copy"><small>Symptom entry</small><h3>증상만 알고 있어요</h3><p>기대값 120과 실제값 70을 단서로 검증할 후보 메커니즘을 좁힙니다.</p><strong>증상에서 시작</strong></span></a></div><div class="join"><b>Merge · same reducer / same content / same progress</b></div>
</div></section>
<section id="protocol" class="procedure" aria-label="공통 학습 프로토콜"><article class="phase"><b>01</b><h3>Predict</h3><p>실행 전에 최종값을 선택합니다.</p></article><article class="phase"><b>02</b><h3>Observe</h3><p>두 세션의 순서를 한 단계씩 봅니다.</p></article><article class="phase"><b>03</b><h3>Compare</h3><p>기대값 120과 결과 70을 대조합니다.</p></article><article class="phase"><b>04</b><h3>Explain</h3><p>150이 사라진 인과를 직접 정리합니다.</p></article><article class="phase"><b>05</b><h3>Transfer</h3><p>같은 원리를 새 재고 사례에 적용합니다.</p></article></section>
<section class="scenario" aria-labelledby="scenario-title"><div class="scenario-title"><small>SHARED SCENARIO · TX-LOST-UPDATE-01</small><h2 id="scenario-title">Expected 120.<br/>Observed 70.</h2><p>A는 +50, B는 −30을 계산합니다. 둘 다 같은 초기값을 읽었기 때문에 마지막 쓰기가 앞선 결과를 덮습니다.</p><div class="values"><span>Initial<b>100</b></span><span>Expected<b>120</b></span><span class="alert">Observed<b>70</b></span></div></div><div class="schedule" role="table" aria-label="Lost Update 실행 순서"><div class="schedule-head"><span>Execution schedule</span><span>A / B</span></div><div class="event" role="row"><span class="n" role="cell">01</span><span role="cell">A reads 100</span><span class="session-b" role="cell">B waiting</span></div><div class="event" role="row"><span class="n" role="cell">02</span><span role="cell">A holds 100</span><span class="session-b" role="cell">B reads 100</span></div><div class="event" role="row"><span class="n" role="cell">03</span><span role="cell">A writes 150</span><span class="session-b" role="cell">B computes 70</span></div><div class="event conflict" role="row"><span class="n" role="cell">04</span><span role="cell">150 is overwritten</span><span class="session-b" role="cell">B writes 70</span></div></div></section>
<aside class="notice"><b>교육 범위 / Guided scenario</b><p>이 매뉴얼은 결정론적 교육 시나리오입니다. 실제 운영 로그나 데이터베이스에 연결하지 않고, 장애 원인을 진단하거나 확정하지 않습니다.</p></aside>
</main>
<footer><span>Active depth · Transaction Isolation / Lost Update</span><span>다음 주제는 이 모듈 완성 후 활성화</span></footer>
</body>
</html>
@@ -0,0 +1,175 @@
artifact-kind: design-direction-concept
artifact-version: 1
identity:
direction-id: ledger-studio
representative-screen-id: dual-entry-shared-lab
producer-run-id: hyeonworks-vnext-v1-direction-divergence-ledger-20260718T1135Z
context-package-sha256: 52ef4f1c5e433157bc23eb801d3fb122923d7f50d667af41019be3b4b1489c83
visual-thesis: >-
Lost Update를 홍보용 카드 모음이나 장애 콘솔이 아니라, 독자가 가설과 관찰값을 직접 대조하는
편집형 기술 저널의 한 페이지로 보인다. 서로 다른 두 entry는 대칭인 orientation 칼럼으로만
분리되고, 굵은 merge rule 아래에서 동일한 tx-lost-update-01 causal ledger로 합류한다.
깊이는 콘텐츠 수가 아니라 100을 함께 읽은 두 세션이 150과 70을 차례로 쓰며 기대값 120을
잃는 한 사례의 인과를 감사 가능하게 드러내는 데서 나온다.
reference-clusters:
- id: scientific-lab-notebook
role: "학습을 수동 독서가 아닌 검증 기록으로 보이게 하는 구조"
signals:
- "가설, 관찰, 결과가 규칙선과 여백 주석으로 이어진다."
- "관찰 사실과 해석을 서로 다른 라벨로 분리한다."
translation:
- "Predict → Observe → Compare → Explain → Transfer를 순서가 있는 기록 행으로 사용한다."
- "coral은 Observed 값과 증상 단서처럼 증거가 있는 지점에만 쓴다."
guardrail: "낡은 종이 질감, 손글씨 장식, 과학실 소품을 문자 그대로 모사하지 않는다."
- id: long-form-technical-journal
role: "활성 주제 하나의 깊이를 카탈로그 규모보다 먼저 전달하는 편집 위계"
signals:
- "큰 serif 논제, 짧은 dek, 넓은 여백이 읽기 시작점을 만든다."
- "masthead와 folio 표기가 한 호의 기술 논문처럼 현재 범위를 명시한다."
translation:
- "왼쪽 hero는 질문을, 오른쪽 folio는 검증할 값과 실행 순서를 맡는다."
- "Atlas는 Concurrency → Transaction Isolation → Lost Update 한 줄만 보여준다."
guardrail: "완성되지 않은 주제를 거대한 지도나 콘텐츠 카탈로그로 부풀리지 않는다."
- id: double-entry-ledger
role: "초기값, 기대값, 관찰값과 두 세션의 쓰기를 같은 기준선에서 감사"
signals:
- "숫자가 고정 폭 열과 수평 rule 위에 정렬된다."
- "Initial / Expected / Observed 명칭이 값의 의미를 색과 무관하게 설명한다."
translation:
- "100 / 120 / 70을 한 ledger folio에 놓고 70만 evidence-coral로 강조한다."
- "A reads 100, B reads 100, A writes 150, B writes 70을 번호 행으로 기록한다."
guardrail: "금융 대시보드, KPI 카드, 장식용 표처럼 보이게 만들지 않는다."
- id: scholarly-margin-annotation
role: "본문 흐름을 방해하지 않고 범위와 증거 상태를 명시하는 주석 체계"
signals:
- "작은 mono label과 folio 번호가 본문 serif와 분명히 구별된다."
- "guided scenario 한계가 결과 근처에 붙어 과잉 해석을 막는다."
translation:
- "tx-lost-update-01, Shared scenario, Guided scenario를 evidence annotation으로 둔다."
- "실제 로그나 운영 DB를 분석하지 않고 원인을 확정하지 않는다는 경계를 평문으로 쓴다."
guardrail: "사건 파일, 경고 테이프, 확정 진단 어휘를 사용하지 않는다."
- id: causal-process-plate
role: "두 세션의 시간 순서를 장식적 다이어그램 없이 읽히게 하는 인쇄 도식"
signals:
- "번호, 동사, 값, 짧은 rule로 사건의 선후 관계를 고정한다."
- "DOM 읽기 순서와 시각 순서가 같아 선형으로도 이해된다."
translation:
- "read / write를 네 개의 trace row로 표시하고 세션 이름을 텍스트로 유지한다."
- "모바일에서도 같은 행 순서를 바꾸지 않고 열만 압축한다."
guardrail: "node canvas, 드래그 타임라인, neon terminal 창으로 바꾸지 않는다."
layout-grammar:
desktop:
viewport: "1280px 기준, 최대 1440px editorial spread"
rules:
- "상단은 짧은 Atlas breadcrumb, 그 아래 hero를 약 1.05:0.95 비대칭 2열로 구성한다."
- "왼쪽은 thesis/dek/promise, 오른쪽은 tx-lost-update-01 ledger folio를 배치한다."
- "orientation 영역만 동일 폭 2열 entry로 나누고 이후 학습 구조는 단일 열로 합친다."
- "merge-rule은 두 entry 바로 아래에서 Same Lost Update Lab을 한 번만 선언한다."
- "공통 학습 루프는 다섯 열, guided-scenario 경계는 그 아래 한 개의 주석 띠로 둔다."
compact:
viewport: "360780px"
rules:
- "hero, entry, loop, boundary를 DOM 순서 그대로 한 열로 적층한다."
- "420px 이하에서 masthead를 두 줄로 쌓고 ledger value를 세 행으로 바꿔 가로 넘침을 막는다."
- "merge label, trace row, footer는 줄바꿈을 허용하되 의미 순서는 유지한다."
spacing:
base: "8px 계열의 여백을 쓰되 hero와 section 사이에는 3280px의 editorial pause를 둔다."
density: "증거 folio 내부는 조밀하게, orientation 설명 주변은 넓게 두어 읽기 속도를 분리한다."
interaction-grammar:
entry-selection:
- "개념을 알고 있어요와 증상만 알고 있어요는 native anchor이며 둘 다 #shared-lab으로 이동한다."
- "두 선택은 orientation 카피만 다르고 tx-lost-update-01, 진행 상태, 학습 콘텐츠를 분기하지 않는다."
- "hover와 focus-visible에서 동일한 mint 바탕을 주고 3px focus outline을 별도로 유지한다."
reading-flow:
- "skip link는 키보드 사용자를 entry heading으로 바로 보낸다."
- "이후 순서는 Predict → Observe → Compare → Explain → Transfer로 고정한다."
- "실험 실행 화면으로 확장할 때 상태 변화는 aria-live로 알리되 focus를 강제로 이동하지 않는다."
motion:
default: "합류선을 그리거나 evidence row를 강조한다면 짧은 1회 전환만 허용한다."
reduced: "prefers-reduced-motion에서는 smooth scroll과 모든 전환을 제거하고 최종 상태를 즉시 표시한다."
input:
- "필수 동작은 Tab, Enter, Space로 가능해야 하며 drag-only 조작을 두지 않는다."
- "색은 보조 신호이고 상태명, 숫자, 순번, 동사를 항상 함께 제공한다."
type-grammar:
display:
family: "Georgia 계열 humanist serif"
use: "논제, entry 질문, scenario 제목, 단계 설명"
behavior: "큰 크기와 촘촘한 자간으로 편집적 긴장을 만들되 장문 본문에는 쓰지 않는다."
body:
family: "Inter 또는 system neutral grotesk"
use: "dek, 안내, 경계 설명, 링크 동작"
behavior: "한국어 본문은 최소 16px에 가까운 크기와 1.5 line-height를 유지한다."
evidence:
family: "ui-monospace 또는 system monospace"
use: "scenario id, 값, 순번, atlas path, evidence label"
behavior: "영문 uppercase label은 짧게 쓰고 한국어 핵심 설명을 mono로 대체하지 않는다."
hierarchy-rule: "serif는 논제와 설명, mono는 id·값·증거 주석이라는 역할 경계를 넘지 않는다."
primitive-grammar:
dominant:
- name: entry-column
anatomy: "entry tag + serif question + orientation copy + text arrow"
rule: "두 개만 나란히 두며 선택 이후에는 반복하지 않는다."
- name: causal-ledger
anatomy: "Initial / Expected / Observed values + ordered read/write rows"
rule: "값과 순서를 같은 folio 안에서 비교해 덮어쓰기를 감사 가능하게 한다."
- name: evidence-margin
anatomy: "mono label + 범위 설명"
rule: "Guided scenario와 증거 상태를 본문 가까이에 둔다."
exclusive:
- name: merge-rule
rule: "두 수평선이 하나의 Same Lost Update Lab seal로 합류한다."
- name: ledger-folio
rule: "흰 종이 면, 검은 상단 rule, 한 방향의 offset shadow만 사용한다."
- name: annotated-value
rule: "label과 숫자를 함께 표시하며 관찰값만 coral로 제한한다."
prohibited:
- "node canvas 또는 거대한 Atlas map"
- "terminal window 또는 neon hacker styling"
- "glass card grid 또는 보라 SaaS gradient"
- "incident 확정 진단을 암시하는 경고 primitive"
color-and-rule-grammar:
paper: {value: "#f5f1e8", role: "집중 읽기용 페이지 바탕"}
ink: {value: "#17201f", role: "본문과 인과 경계"}
evidence-coral: {value: "#bb4d3b", role: "관찰 결과와 증상 단서에만 사용"}
orientation-violet: {value: "#4b4f96", role: "비진단 entry와 step index의 작은 보조 신호"}
focus: {value: "#075d58", role: "명시적 keyboard focus outline"}
ledger-rule: {value: "1px solid #17201f", role: "합류와 비교 경계, 카드 테두리 남용 금지"}
content-contract:
shared-scenario-id: tx-lost-update-01
entry-labels: ["개념을 알고 있어요", "증상만 알고 있어요"]
atlas-path: [Concurrency, Transaction Isolation, Lost Update]
values: {initial: 100, expected: 120, observed: 70, session-a-write: 150, session-b-write: 70}
learning-loop: [Predict, Observe, Compare, Explain, Transfer]
trust-boundary: >-
교육용 고정 guided scenario이며 실제 로그나 운영 데이터베이스를 분석하지 않고 장애 원인을
확정하지 않는다. 증상 entry는 검증할 후보 메커니즘만 좁힌다.
roadmap-honesty: "현재 활성 depth는 Transaction Isolation / Lost Update 하나이며 다음 주제는 비활성으로 표시한다."
accessibility-contract:
- "360/768/1280px에서 가로 스크롤 없이 읽기 순서와 합류 의미를 보존한다."
- "native anchor, skip link, 명시적 focus-visible을 사용한다."
- "세션, 단계, 값, 경계는 텍스트와 숫자로 명명해 색에만 의존하지 않는다."
- "reduced-motion에서 smooth scroll과 전환을 제거한다."
- "확장되는 실험 상태는 aria-live로 알리고 예측 가능한 focus 순서를 유지한다."
coded-slice-evidence:
file: index.html
verified-structure:
- "두 한국어 entry가 동일 #shared-lab anchor를 참조한다."
- "tx-lost-update-01 folio에 100 / 120 / 70과 네 개의 read/write 행이 있다."
- "공통 Predict / Observe / Compare / Explain / Transfer 루프가 있다."
- "Guided scenario 한계 문구가 있다."
- "max-width 780px와 420px 재배치, focus-visible, prefers-reduced-motion 대안이 있다."
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Hyeonworks — Ledger Studio direction</title>
<style>
:root{--paper:#f5f1e8;--ink:#17201f;--muted:#5c6561;--line:#b8b5a9;--violet:#4b4f96;--coral:#bb4d3b;--mint:#dce9df;--white:#fffdfa;--focus:#075d58}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink);font-family:Inter,ui-sans-serif,system-ui,sans-serif;line-height:1.5}
a{color:inherit}.skip{position:absolute;left:1rem;top:-5rem;background:var(--ink);color:white;padding:.7rem 1rem;z-index:20}.skip:focus{top:1rem}
:focus-visible{outline:3px solid var(--focus);outline-offset:4px}
header{display:flex;justify-content:space-between;align-items:center;padding:1.1rem clamp(1rem,4vw,4.5rem);border-bottom:1px solid var(--line);font-size:.78rem;letter-spacing:.08em;text-transform:uppercase}
.brand{font-weight:800}.edition{color:var(--muted)}
main{max-width:1440px;margin:auto;padding:clamp(2rem,6vw,6rem) clamp(1rem,5vw,5rem)}
.atlas{display:flex;gap:.7rem;align-items:center;color:var(--muted);font:600 .73rem/1.2 ui-monospace,monospace;text-transform:uppercase;letter-spacing:.06em}.atlas b{color:var(--ink)}.atlas span{color:var(--coral)}
.hero{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(360px,.95fr);gap:clamp(2rem,7vw,8rem);padding:clamp(2rem,5vw,5rem) 0 4rem;border-bottom:1px solid var(--ink)}
h1{font-family:Georgia,serif;font-weight:500;font-size:clamp(3.1rem,7vw,7.4rem);line-height:.91;letter-spacing:-.055em;margin:1.2rem 0 1.5rem;max-width:11ch}.dek{max-width:56ch;font-size:clamp(1rem,1.4vw,1.2rem);color:#36403d}
.promise{display:flex;gap:1rem;align-items:center;margin-top:2rem;font-size:.86rem;font-weight:700}.promise:before{content:"";width:3.6rem;border-top:2px solid var(--coral)}
.folio{align-self:end;border-top:6px solid var(--ink);background:var(--white);padding:1.2rem 1.3rem 1.4rem;box-shadow:10px 10px 0 #d7d1c4}
.folio-head{display:flex;justify-content:space-between;font:700 .72rem ui-monospace,monospace;text-transform:uppercase;letter-spacing:.07em;border-bottom:1px solid var(--line);padding-bottom:.8rem}.scenario{font-family:Georgia,serif;font-size:1.65rem;margin:1.2rem 0 .25rem}.values{display:grid;grid-template-columns:repeat(3,1fr);margin:1rem 0;border-block:1px solid var(--line)}.value{padding:.9rem .5rem}.value+.value{border-left:1px solid var(--line)}.value small{display:block;color:var(--muted);font-size:.68rem;text-transform:uppercase}.value b{font:700 1.7rem ui-monospace,monospace}.value:last-child b{color:var(--coral)}
.trace{display:grid;gap:.48rem;font:.77rem ui-monospace,monospace}.trace-row{display:grid;grid-template-columns:1.8rem 5rem 1fr;gap:.6rem;align-items:center}.trace-row i{height:1px;background:var(--line)}
.entry-title{display:flex;align-items:end;justify-content:space-between;gap:2rem;margin:3.6rem 0 1.25rem}.entry-title h2{font:500 clamp(2rem,4vw,4rem)/1 Georgia,serif;margin:0;max-width:13ch}.entry-title p{max-width:38ch;color:var(--muted);margin:0}
.entries{display:grid;grid-template-columns:1fr 1fr;border:1px solid var(--ink);background:var(--white)}
.entry{padding:clamp(1.4rem,3vw,2.6rem);min-height:300px;display:flex;flex-direction:column;text-decoration:none;position:relative}.entry+.entry{border-left:1px solid var(--ink)}.entry:hover,.entry:focus-visible{background:var(--mint)}.entry-tag{font:700 .7rem ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em;color:var(--violet)}.entry h3{font:500 clamp(1.7rem,3vw,2.8rem)/1.05 Georgia,serif;margin:1rem 0}.entry p{max-width:38ch;color:#424b48}.entry .arrow{margin-top:auto;font-weight:800}.entry .arrow:after{content:" →"}.entry.symptom .entry-tag{color:var(--coral)}
.merge{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:1rem;margin:1.2rem 0 0;font:700 .72rem ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em}.merge:before,.merge:after{content:"";border-top:1px solid var(--ink)}.merge strong{padding:.55rem .8rem;background:var(--ink);color:var(--paper)}
.loop{display:grid;grid-template-columns:repeat(5,1fr);margin-top:4.5rem;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.step{padding:1.2rem .8rem;min-height:110px}.step+.step{border-left:1px solid var(--line)}.step b{display:block;font:700 .72rem ui-monospace,monospace;color:var(--violet);margin-bottom:.45rem}.step span{font-family:Georgia,serif;font-size:1.05rem}
.boundary{margin-top:2rem;display:grid;grid-template-columns:auto 1fr;gap:1rem;align-items:start;background:#ebe4d7;padding:1rem 1.2rem}.boundary b{font:.7rem ui-monospace,monospace;text-transform:uppercase;color:var(--coral)}.boundary p{margin:0;font-size:.88rem}
footer{display:flex;justify-content:space-between;padding:2rem clamp(1rem,5vw,5rem);border-top:1px solid var(--line);color:var(--muted);font-size:.75rem}
@media(max-width:780px){.hero{grid-template-columns:1fr}.folio{max-width:560px}.entry-title{align-items:start;flex-direction:column}.entries{grid-template-columns:1fr}.entry+.entry{border-left:0;border-top:1px solid var(--ink)}.entry{min-height:235px}.loop{grid-template-columns:1fr}.step{min-height:auto;display:grid;grid-template-columns:5rem 1fr}.step+.step{border-left:0;border-top:1px solid var(--line)}.boundary{grid-template-columns:1fr}footer{display:block}}
@media(max-width:420px){header{align-items:flex-start;flex-direction:column;gap:.35rem}.edition{text-transform:none;letter-spacing:.04em}.atlas{align-items:flex-start;flex-wrap:wrap;line-height:1.6}.folio{box-shadow:6px 6px 0 #d7d1c4}.values{grid-template-columns:1fr}.value{display:grid;grid-template-columns:1fr auto;align-items:end}.value+.value{border-left:0;border-top:1px solid var(--line)}.trace-row{grid-template-columns:1.5rem minmax(0,7.5rem) 1fr}.merge{gap:.55rem}.merge strong{text-align:center}footer span{display:block}footer span+span{margin-top:.35rem}}
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*{transition:none!important}}
</style>
</head>
<body>
<a class="skip" href="#entries">출발점으로 건너뛰기</a>
<header><div class="brand">Hyeonworks / Technology Atlas</div><div class="edition">Field note 001 · Transaction Isolation</div></header>
<main>
<div class="atlas"><span>Concurrency</span> / Transaction Isolation / <b>Lost Update</b></div>
<section class="hero" aria-labelledby="title">
<div><h1 id="title">결과보다 먼저, 원인을 예측합니다.</h1><p class="dek">기술 개념을 알고 있어도, 이상한 결과만 알고 있어도 괜찮습니다. 서로 다른 단서에서 출발해 같은 Lost Update 실험에서 상태 변화를 검증합니다.</p><div class="promise">두 입구, 하나의 학습 Lab</div></div>
<aside class="folio" aria-label="Lost Update 실험 미리보기"><div class="folio-head"><span>Shared scenario</span><span>tx-lost-update-01</span></div><div class="scenario">두 요청은 성공했는데, 왜 70일까요?</div><div class="values"><div class="value"><small>Initial</small><b>100</b></div><div class="value"><small>Expected</small><b>120</b></div><div class="value"><small>Observed</small><b>70</b></div></div><div class="trace" aria-label="실행 순서"><div class="trace-row"><b>01</b><span>A reads 100</span><i></i></div><div class="trace-row"><b>02</b><span>B reads 100</span><i></i></div><div class="trace-row"><b>03</b><span>A writes 150</span><i></i></div><div class="trace-row"><b>04</b><span>B writes 70</span><i></i></div></div></aside>
</section>
<section id="entries" aria-labelledby="entry-heading"><div class="entry-title"><h2 id="entry-heading">어떤 단서를 가지고 있나요?</h2><p>두 경로는 짧은 준비 방식만 다릅니다. 실험과 설명, 진행 상태는 완전히 같습니다.</p></div>
<div class="entries"><a class="entry" href="#shared-lab"><span class="entry-tag">Start 01 · concept</span><h3>개념을 알고 있어요</h3><p>Transaction Isolation에서 Lost Update가 어디에 위치하고 어떤 충돌이 필요한지 관계부터 정리합니다.</p><span class="arrow">개념에서 시작</span></a><a class="entry symptom" href="#shared-lab"><span class="entry-tag">Start 02 · symptom</span><h3>증상만 알고 있어요</h3><p>기대값 120, 실제값 70이라는 단서를 좁혀 검증할 후보 메커니즘을 찾습니다.</p><span class="arrow">증상에서 시작</span></a></div>
<div class="merge" id="shared-lab"><strong>Same Lost Update Lab</strong></div>
</section>
<section class="loop" aria-label="공통 학습 루프"><div class="step"><b>01 · Predict</b><span>결과를 먼저 고릅니다</span></div><div class="step"><b>02 · Observe</b><span>두 세션을 실행합니다</span></div><div class="step"><b>03 · Compare</b><span>120과 70을 비교합니다</span></div><div class="step"><b>04 · Explain</b><span>덮어쓰기 인과를 씁니다</span></div><div class="step"><b>05 · Transfer</b><span>새 재고 사례에 적용합니다</span></div></section>
<aside class="boundary"><b>Guided scenario</b><p>이 화면은 교육용 고정 시나리오입니다. 실제 로그나 운영 데이터베이스를 분석하지 않으며, 장애 원인을 확정하지 않습니다.</p></aside>
</main>
<footer><span>Active depth: Transaction Isolation / Lost Update</span><span>Next topics stay inactive until complete.</span></footer>
</body>
</html>
@@ -0,0 +1,191 @@
artifact-kind: visual-direction-concept
artifact-version: 1
identity:
concept-id: hyeonworks-vnext-v1-signal-trace
direction-id: signal-trace
representative-screen-id: dual-entry-shared-lab
producer-run-id: hyeonworks-vnext-v1-direction-divergence-signal-20260718T1135Z
basis:
context-package: hyeonworks/state/context-packages/hyeonworks-vnext-v1-direction/DES-VISUAL-20260718T113223Z.pkg.yaml
context-package-sha256: f2a89ef290b57ed34610b08e30b2a23d07e7244fec0991de81d61ca157d8ccf6
assigned-question: >-
두 세션의 신호와 값 흐름을 계측 rail처럼 추적하면 Lost Update의 시간적 인과를
더 빨리 이해하는가?
visual-thesis: >-
서로 다른 두 입력 단서를 밝은 계측 band로 분리해 시작하고, 명시적인 merge bus에서
하나의 Lab으로 수렴시킨 뒤, 같은 초기값을 읽고 150과 70을 순서대로 쓰는 과정을 단일
transaction trace로 읽게 한다. 화면의 개성은 terminal 표면이나 장식적 dashboard가 아니라
선로의 합류, 시간축 정렬, 숫자 probe, 충돌 hatch가 만드는 판독성에서 나온다. 두 진입의
차이는 orientation 메타데이터에만 남고, 합류 뒤에는 tx-lost-update-01과 공통 학습 루프를
오직 한 번만 렌더링한다.
reference-clusters:
- id: railway-junction-schematic
source-family: "railway signal diagram / interlocking schematic"
useful-signal: "독립된 입력선이 이름 붙은 junction에서 하나의 선로로 합류하는 구조"
translation:
- "CONCEPT와 SYMPTOM을 좌우 entry band로 분리한다."
- "band 아래 두 선은 SAME LAB 스탬프에서 합쳐지고 이후에는 한 선만 유지한다."
- "합류 전에는 두 band, 합류 후에는 하나의 trace table이라는 수량 규칙을 지킨다."
boundary: "실제 철도 관제 UI, 신호등 색 체계, 사실적인 선로 일러스트를 모사하지 않는다."
- id: logic-analyzer-timing-sheet
source-family: "logic analyzer trace / timing diagram"
useful-signal: "공통 시간축에 채널별 상태와 read/compute/write 선후관계를 정렬하는 방식"
translation:
- "01~04 행을 고정된 실행 순서로 두고 Session A와 B를 같은 행에서 비교한다."
- "현재 행동과 대기 상태를 같은 셀 문법으로 기록하며 장황한 서술 대신 동사+값을 쓴다."
- "마지막 overwrite 행 전체에 hatch를 적용해 충돌 구간의 범위를 드러낸다."
boundary: "파형, 네온 glow, 검은 console, 실행 중인 모니터링 제품처럼 보이는 chrome은 쓰지 않는다."
- id: calibration-instrument-panel
source-family: "industrial calibration panel / labeled value probe"
useful-signal: "숫자와 단위를 장식 없이 비교하고 각 값의 역할을 짧은 태그로 고정하는 방식"
translation:
- "Initial 100, Expected 120, Observed 70을 동일 크기의 value probe로 나란히 둔다."
- "Observed의 위험 신호는 red뿐 아니라 ALERT 문맥과 별도 probe 상태로 중복 부호화한다."
- "숫자는 tabular 형태의 monospace로 정렬하고 본문보다 강한 크기 대비를 준다."
boundary: "probe를 성과 지표 카드나 실시간 KPI로 늘리지 않고 이 시나리오의 세 값으로 제한한다."
- id: engineering-hatch-legend
source-family: "engineering drawing hatch / printed channel legend"
useful-signal: "색을 볼 수 없어도 패턴, 기호, 라벨로 채널과 예외 영역을 구분하는 방식"
translation:
- "Session A는 circle, Session B는 diamond와 반복 라벨로 구분한다."
- "Session B entry edge와 overwrite 구간에는 서로 다른 밀도의 diagonal hatch를 쓴다."
- "legend는 trace 바로 위에 놓아 기호를 기억해야 하는 거리를 줄인다."
boundary: "경찰 테이프나 incident severity 장식으로 읽힐 정도의 과한 경고 패턴은 피한다."
layout-grammar:
topology: "dual input bands → centered merge bus → single shared Lab → common learning loop → boundary"
desktop:
viewport-reference: "1280px 이상"
grid: "intro는 1.15fr/0.85fr, entry bands는 1fr/1fr, Lab은 0.65fr/1.35fr"
reading-order:
- "Atlas breadcrumb와 visual thesis"
- "GUIDED SCENARIO 경계"
- "두 entry band"
- "SAME LAB merge bus"
- "Lab metadata와 value probes"
- "transaction trace"
- "Predict → Observe → Compare → Explain → Transfer"
- "learning boundary"
density-rule: "trace는 중간~높은 밀도를 허용하되 entry 영역은 선택지 두 개 외의 카드를 만들지 않는다."
compact:
breakpoint-contract: "800px 이하에서 intro, bands, Lab을 한 열로 재배치하고 420px 이하에서 probe를 한 열로 쌓는다."
trace-reflow: "각 시간 행은 step 번호 다음 A 셀, B 셀 순으로 선형화하며 DOM 읽기 순서는 바꾸지 않는다."
merge-rule: "공간이 부족하면 장식용 두 번째 branch를 줄여도 SAME LAB 스탬프와 단일 도착점은 유지한다."
minimum-width: "360px에서 가로 스크롤 없이 boundary와 Transfer까지 도달한다."
spacing-rule: "rail 사이 수직 간격보다 섹션 전환 간격을 크게 해 입력·합류·실험의 위계를 보존한다."
interaction-grammar:
entry-selection:
control: "두 entry band 전체를 native link 또는 button으로 제공한다."
behavior: "어느 band를 선택해도 동일한 #shared Lab으로 이동하고 entryMode만 orientation 메타데이터로 기록한다."
state-boundary: "합류 후 scenarioId, reducer state, 단계 목록, 정답·설명 콘텐츠를 entry별로 복제하거나 분기하지 않는다."
keyboard: "Tab으로 각 band에 도달하고 Enter로 선택하며 focus-visible outline을 항상 표시한다."
trace-progression:
controls: "production 단계 제어는 native previous/next button을 사용하며 drag를 필수 입력으로 만들지 않는다."
announcement: "단계가 바뀌면 aria-live에 step 번호, session, action, value를 한 번 알리고 사용자의 focus는 이동시키지 않는다."
active-state: "현재 segment는 굵기+기호+짧은 pulse로 강조하며 완료·대기·충돌을 색만으로 구분하지 않는다."
learning-loop: "Predict를 완료한 뒤 Observe를 열고 Compare, Explain, Transfer 순서를 하나의 progress 구조로 유지한다."
prototype-scope: >-
현재 coded slice는 두 native anchor가 동일한 #shared DOM target을 참조해 shared-core 구조를
증명한다. runtime reducer 실행이나 실제 DB 연결을 시뮬레이션한다고 주장하지 않는다.
type-grammar:
display:
family: "precise grotesk sans; coded slice fallback은 Arial, Helvetica, sans-serif"
role: "한 화면의 질문과 Lab 목적만 크게 표시"
treatment: "큰 x-height, 단단한 800 weight, 음수 letter-spacing; 장식적 italic 금지"
body:
family: "same sans family"
role: "학습 의도, 경계, 짧은 인과 설명"
measure: "한 문단 최대 약 58ch"
signal-label:
family: "ui-monospace, monospace"
role: "scenario id, channel code, execution step, compact uppercase label"
treatment: "0.68~0.82rem, 700~800 weight, 제한된 letter-spacing"
numeric:
family: "ui-monospace, monospace"
treatment: "font-variant-numeric: tabular-nums; Initial/Expected/Observed의 baseline을 맞춘다."
language-rule: "한국어를 기본 설명 언어로 두고 SQL·transaction 동작과 compact signal label만 원어를 병기한다."
primitive-grammar:
entry-band:
anatomy: "channel code + 한국어 진입 라벨 + orientation 단서 + 방향 표시"
rule: "합류 전에 정확히 두 개만 존재하고 각 band의 edge treatment로 채널을 중복 부호화한다."
merge-bus:
anatomy: "두 branch line + centered SAME LAB stamp + scenario id"
rule: "두 진입이 별도 결과로 이어진다는 인상을 주는 분기선을 Lab 아래로 연장하지 않는다."
trace-rail:
anatomy: "step number + Session A cell + Session B cell"
rule: "행은 실제 실행 순서를 보존하고 read/compute/write 동사와 값을 함께 기록한다."
signal-stamp:
anatomy: "짧은 uppercase 상태 + 필요 시 scenario id"
rule: "merge와 경계처럼 의미가 고정된 지점에만 쓰며 badge cloud로 늘리지 않는다."
conflict-hatch:
anatomy: "diagonal pattern + overwrite text + Session B diamond"
rule: "충돌이 일어난 04행에만 적용하고 red 단독 의미 전달을 금지한다."
value-probe:
anatomy: "role label + tabular numeric value"
rule: "Initial/Expected/Observed 세 값의 동일 비교에만 사용한다."
semantic-encoding:
tokens:
background: { value: "#eef2f2", role: "밝은 계측 바탕" }
panel: { value: "#f9fbfa", role: "entry와 Lab 표면" }
ink: { value: "#102b31", role: "본문과 구조선" }
trace-blue: { value: "#176c8a", role: "Session A와 concept input", non-color-cue: "circle + CONCEPT/A label" }
trace-red: { value: "#a24a3f", role: "Session B와 overwrite", non-color-cue: "diamond + hatch + SYMPTOM/B label" }
merge-green: { value: "#165f54", role: "공통 Lab junction", restriction: "일반 CTA에는 사용하지 않음" }
focus: { value: "#7b2e95", role: "키보드 focus outline 전용" }
contrast-rule: "본문, signal label, control 상태는 panel/background 위 WCAG AA 대비를 유지한다."
status-rule: "모든 의미 상태는 text, symbol, shape, pattern 중 최소 하나를 color와 함께 제공한다."
motion-grammar:
allowed: "사용자가 실행한 현재 rail segment의 짧고 국소적인 pulse"
timing: "한 번의 180~240ms emphasis; 자동 반복과 배경 이동 금지"
reduced-motion: "prefers-reduced-motion에서는 pulse를 제거하고 3px line, pattern, CURRENT text로 대체"
prohibited: "glow, scanline, terminal cursor, 자동 재생 trace, parallax"
content-contract:
required-copy:
entries: ["개념을 알고 있어요", "증상만 알고 있어요"]
scenario-id: tx-lost-update-01
guided-label: GUIDED SCENARIO
values: { initial: 100, expected: 120, observed: 70, a-write: 150, b-write: 70 }
learning-loop: [Predict, Observe, Compare, Explain, Transfer]
atlas-path: [Concurrency, Transaction Isolation, Lost Update]
trust-boundary: >-
운영 로그를 분석하거나 실제 장애 원인을 확정하지 않으며, 고정된 조건에서 검증할 후보
메커니즘을 학습한다는 설명을 symptom 진입과 Lab 주변에 유지한다.
coded-slice-map:
entry-bands: "index.html #entry .bands; 두 href가 모두 #shared"
merge-junction: "index.html .bus .merge; same lab · tx-lost-update-01"
shared-lab: "index.html section#shared.lab; 문서에 단 한 번 존재"
transaction-trace: "index.html .trace [role=table]; 100 read → 150/70 write의 4단계"
common-loop: "index.html .loop; shared Lab 뒤에 단 한 번 존재"
guided-boundary: "index.html .readout과 .boundary"
accessibility-contract:
- "skip link는 두 entry band가 시작되는 #entry로 이동한다."
- "entry control과 향후 step control은 native semantics를 유지한다."
- "focus-visible은 배경과 분명히 구별되는 3px outline과 offset을 사용한다."
- "trace는 table/row/cell 의미를 제공하고 모바일에서도 step→A→B 읽기 순서를 유지한다."
- "360/768/1280px에서 가로 스크롤 없이 공통 학습 루프를 끝까지 읽고 조작할 수 있어야 한다."
- "색 외에 circle/diamond, label, hatch, text로 session과 충돌을 구분한다."
- "prefers-reduced-motion에서는 모든 비필수 transition과 animation을 제거한다."
do-not-use:
- neon terminal 또는 검은 hacker console
- glow, scanline, 실제 monitoring chrome
- dashboard KPI grid
- 색만으로 구분한 session과 충돌
- drag 전용 timeline
- 실제 장애 진단 또는 원격 DB 분석을 암시하는 카피
- 과장된 atlas map이나 준비 중 콘텐츠의 가짜 catalog
- 합류 후 entry별 Lab, reducer, progress, 설명 콘텐츠 복제
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Hyeonworks — Signal Trace direction</title>
<style>
:root{--bg:#eef2f2;--panel:#f9fbfa;--ink:#102b31;--muted:#52686c;--line:#9bb0b2;--a:#176c8a;--b:#a24a3f;--merge:#165f54;--soft:#dcebea;--focus:#7b2e95}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:Arial,Helvetica,sans-serif;line-height:1.45}a{color:inherit}.skip{position:absolute;top:-5rem;left:1rem;background:var(--ink);color:white;padding:.7rem 1rem;z-index:5}.skip:focus{top:1rem}:focus-visible{outline:3px solid var(--focus);outline-offset:4px}
header{min-height:64px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding:0 clamp(1rem,4vw,4rem);border-bottom:1px solid var(--line);font-size:.72rem;text-transform:uppercase;letter-spacing:.1em}.brand{font-weight:900}.status{justify-self:center;display:flex;align-items:center;gap:.5rem}.status:before{content:"";width:.55rem;height:.55rem;border-radius:50%;background:var(--merge)}.scope{justify-self:end;color:var(--muted)}
main{max-width:1500px;margin:auto;padding:clamp(1.2rem,4vw,4rem)}.path{font:700 .72rem ui-monospace,monospace;color:var(--muted);letter-spacing:.06em;text-transform:uppercase}.path b{color:var(--ink)}
.intro{display:grid;grid-template-columns:1.15fr .85fr;gap:3rem;align-items:end;padding:clamp(2rem,5vw,5rem) 0 2rem}.intro h1{font-size:clamp(3rem,7vw,7.2rem);line-height:.86;letter-spacing:-.065em;margin:.8rem 0 1.4rem;font-weight:800;max-width:9ch}.intro p{max-width:58ch;color:#334e53;font-size:1.04rem}.readout{background:var(--ink);color:white;padding:1rem 1.1rem;display:grid;grid-template-columns:auto 1fr;gap:.8rem;font:600 .78rem ui-monospace,monospace}.readout b{color:#9fe0d5}.readout p{margin:0;color:#d7e2e1;font:400 .8rem/1.5 Arial,sans-serif}
.entry-label{display:flex;justify-content:space-between;gap:1rem;align-items:end;margin:2.2rem 0 .8rem}.entry-label h2{font-size:1rem;text-transform:uppercase;letter-spacing:.13em;margin:0}.entry-label span{font:700 .7rem ui-monospace,monospace;color:var(--muted)}
.bands{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.band{position:relative;display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:1rem;background:var(--panel);border:1px solid var(--ink);padding:1.2rem;text-decoration:none;min-height:150px;overflow:hidden}.band:before{content:"";position:absolute;left:0;top:0;bottom:0;width:7px;background:var(--a)}.band.symptom:before{background:repeating-linear-gradient(135deg,var(--b) 0 5px,#f4c3bb 5px 10px)}.band:hover{background:var(--soft)}.code{font:800 .72rem ui-monospace,monospace;writing-mode:vertical-rl;transform:rotate(180deg);color:var(--a)}.symptom .code{color:var(--b)}.band h3{font-size:clamp(1.5rem,3vw,2.7rem);line-height:1;margin:.35rem 0}.band p{margin:.7rem 0 0;color:var(--muted);max-width:38ch}.go{font:800 1.5rem ui-monospace,monospace}
.bus{position:relative;height:110px;margin:0 12%;border-left:2px solid var(--a);border-right:2px solid var(--b)}.bus:before,.bus:after{content:"";position:absolute;top:54px;width:50%;border-top:2px solid}.bus:before{left:0;border-color:var(--a);transform:skewY(8deg);transform-origin:left}.bus:after{right:0;border-color:var(--b);transform:skewY(-8deg);transform-origin:right}.merge{position:absolute;left:50%;top:40px;transform:translateX(-50%);background:var(--merge);color:white;padding:.6rem .85rem;font:800 .7rem ui-monospace,monospace;letter-spacing:.08em;text-transform:uppercase;white-space:nowrap}
.lab{background:var(--panel);border:2px solid var(--ink);display:grid;grid-template-columns:minmax(240px,.65fr) 1.35fr}.lab-meta{padding:1.4rem;border-right:1px solid var(--line)}.lab-meta .id{font:700 .7rem ui-monospace,monospace;color:var(--merge)}.lab-meta h2{font-size:clamp(2rem,4vw,4rem);letter-spacing:-.04em;line-height:.95;margin:1rem 0}.measure{display:grid;grid-template-columns:repeat(3,1fr);gap:.5rem;margin-top:1.4rem}.probe{padding:.7rem;background:var(--bg);font:700 .68rem ui-monospace,monospace;text-transform:uppercase}.probe strong{display:block;font-size:1.45rem;color:var(--ink);margin-top:.2rem}.probe.alert strong{color:var(--b)}
.trace{padding:1.5rem;overflow:hidden}.trace-head{display:flex;justify-content:space-between;gap:1rem;font:700 .7rem ui-monospace,monospace;text-transform:uppercase;color:var(--muted);margin-bottom:1.2rem}.rail{display:grid;grid-template-columns:3.4rem 1fr 1fr;align-items:stretch;min-width:0}.rail>*{padding:.75rem .65rem;border-top:1px solid var(--line)}.rail .n{font:700 .7rem ui-monospace,monospace;color:var(--muted)}.cell{position:relative;font:700 .82rem ui-monospace,monospace}.cell:before{content:"";display:inline-block;width:.65rem;height:.65rem;border-radius:50%;background:var(--a);margin-right:.55rem}.cell.b:before{background:var(--b);border-radius:0;transform:rotate(45deg)}.cell.off{color:#879597}.cell.off:before{background:#b7c1c1}.rail.conflict{background:repeating-linear-gradient(135deg,transparent 0 8px,#f3e0dd 8px 16px)}
.loop{display:flex;gap:.55rem;flex-wrap:wrap;margin-top:1rem}.loop span{flex:1;min-width:120px;border:1px solid var(--line);padding:.75rem;font:700 .7rem ui-monospace,monospace;text-transform:uppercase}.loop b{color:var(--merge);margin-right:.4rem}
.boundary{display:flex;gap:1rem;margin-top:1rem;padding:.8rem 1rem;border-left:7px solid var(--merge);background:#d9e5df;font-size:.85rem}.boundary b{white-space:nowrap;text-transform:uppercase;font-size:.7rem;letter-spacing:.06em}
@media(max-width:800px){header{grid-template-columns:1fr auto}.status{display:none}.intro{grid-template-columns:1fr}.bands{grid-template-columns:1fr}.bus{height:80px;margin:0 20%;border-right:0}.bus:after{display:none}.merge{top:27px}.lab{grid-template-columns:1fr}.lab-meta{border-right:0;border-bottom:1px solid var(--line)}.measure{grid-template-columns:repeat(3,1fr)}.rail{grid-template-columns:2.5rem 1fr}.rail .cell:nth-child(3){grid-column:2}.rail .cell:nth-child(3){border-top:0;padding-top:0}.boundary{display:block}.boundary b{display:block;margin-bottom:.3rem}}
@media(max-width:420px){.scope{display:none}header{grid-template-columns:1fr}.measure{grid-template-columns:1fr}.intro h1{font-size:3.3rem}}
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
</style>
</head>
<body><a href="#entry" class="skip">출발 신호로 건너뛰기</a>
<header><div class="brand">Hyeonworks / Signal Trace</div><div class="status">Shared core online</div><div class="scope">Scenario 01 / Lost Update</div></header>
<main><div class="path">Concurrency / Transaction Isolation / <b>Lost Update</b></div>
<section class="intro" aria-labelledby="title"><div><h1 id="title">두 단서가 하나의 원인으로 수렴합니다.</h1><p>개념을 추적하거나 증상을 추적하세요. 입구의 신호만 다르고, 두 경로는 같은 Lost Update 실험과 상태 전이에서 합류합니다.</p></div><aside class="readout"><b>GUIDED<br/>SCENARIO</b><p>운영 로그를 분석하거나 장애 원인을 확정하지 않습니다. 고정된 증상에서 검증할 메커니즘 후보를 배우는 시나리오입니다.</p></aside></section>
<section id="entry" aria-labelledby="entry-title"><div class="entry-label"><h2 id="entry-title">Input channels</h2><span>choose one · merge before experiment</span></div><div class="bands">
<a class="band" href="#shared"><span class="code">CONCEPT</span><span><strong>개념을 알고 있어요</strong><h3>Isolation → conflict</h3><p>격리와 읽기·쓰기 충돌의 관계를 따라갑니다.</p></span><span class="go"></span></a>
<a class="band symptom" href="#shared"><span class="code">SYMPTOM</span><span><strong>증상만 알고 있어요</strong><h3>Expected 120 → got 70</h3><p>성공한 두 요청과 예상 밖의 최종값에서 단서를 좁힙니다.</p></span><span class="go"></span></a>
</div><div class="bus" aria-hidden="true"><span class="merge">same lab · tx-lost-update-01</span></div></section>
<section id="shared" class="lab" aria-labelledby="lab-title"><div class="lab-meta"><div class="id">SHARED LAB / TX-LOST-UPDATE-01</div><h2 id="lab-title">값의 경로를 추적하세요.</h2><p>두 세션은 모두 성공했습니다. 같은 초기값을 읽은 뒤 각자 계산하고 순서대로 썼습니다.</p><div class="measure"><div class="probe">Initial<strong>100</strong></div><div class="probe">Expected<strong>120</strong></div><div class="probe alert">Observed<strong>70</strong></div></div></div>
<div class="trace"><div class="trace-head"><span>execution rail</span><span>A = circle · B = diamond</span></div><div role="table" aria-label="두 세션 실행 순서"><div class="rail" role="row"><div class="n" role="cell">01</div><div class="cell" role="cell">A reads 100</div><div class="cell off" role="cell">B waiting</div></div><div class="rail" role="row"><div class="n" role="cell">02</div><div class="cell off" role="cell">A holds 100</div><div class="cell b" role="cell">B reads 100</div></div><div class="rail" role="row"><div class="n" role="cell">03</div><div class="cell" role="cell">A writes 150</div><div class="cell off" role="cell">B computes 70</div></div><div class="rail conflict" role="row"><div class="n" role="cell">04</div><div class="cell off" role="cell">150 overwritten</div><div class="cell b" role="cell">B writes 70</div></div></div></div></section>
<section class="loop" aria-label="공통 학습 단계"><span><b>01</b>Predict</span><span><b>02</b>Observe</span><span><b>03</b>Compare</span><span><b>04</b>Explain</span><span><b>05</b>Transfer</span></section>
<aside class="boundary"><b>Learning boundary</b><span>이 trace는 결정론적 교육 모델이며 실제 데이터베이스의 모든 스케줄과 격리 구현을 대표하지 않습니다.</span></aside>
</main>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

@@ -0,0 +1,22 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Hyeonworks — direction comparison</title>
<style>
:root{--bg:#11191f;--panel:#1d2931;--ink:#f5f1e8;--muted:#aab6bc;--line:#44545e;--accent:#e7bd45}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:Arial,sans-serif}header{display:flex;justify-content:space-between;align-items:end;gap:2rem;padding:2rem 2.5rem;border-bottom:1px solid var(--line)}h1{font-size:2.2rem;letter-spacing:-.04em;margin:0}.meta{font:700 .7rem ui-monospace,monospace;color:var(--accent);text-transform:uppercase;letter-spacing:.08em}.note{max-width:58ch;color:var(--muted);font-size:.85rem;margin:0}.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;padding:1rem}.direction{margin:0;background:var(--panel);border:1px solid var(--line)}figcaption{display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;font:700 .72rem ui-monospace,monospace;text-transform:uppercase}.tag{color:var(--accent)}.desktop{width:100%;height:auto;display:block;border-top:1px solid var(--line)}.mobile-wrap{padding:1rem;display:flex;justify-content:center;background:#0c1318}.mobile{height:420px;width:auto;display:block;border:1px solid #65747c}footer{padding:1rem 2.5rem 2rem;color:var(--muted);font:700 .68rem ui-monospace,monospace;text-transform:uppercase}
@media(max-width:1000px){.grid{grid-template-columns:1fr}header{display:block}.note{margin-top:1rem}.mobile{height:560px}}
</style>
</head>
<body>
<header><div><div class="meta">Direction cycle 01 · representative screen</div><h1>같은 문제, 서로 다른 세 가지 학습 문법</h1></div><p class="note">모든 방향은 1280×1100과 390×844에서 렌더했습니다. 비교판은 축소 열로 앱을 다시 렌더하지 않고, 동일 viewport의 full-size 결과를 그대로 보여줍니다.</p></header>
<main class="grid">
<figure class="direction"><figcaption><span>01 · Ledger Studio</span><span class="tag">Editorial evidence</span></figcaption><img class="desktop" src="ledger-desktop.png" alt="Ledger Studio 데스크톱 전체 화면"/><div class="mobile-wrap"><img class="mobile" src="ledger-mobile.png" alt="Ledger Studio 모바일 전체 화면"/></div></figure>
<figure class="direction"><figcaption><span>02 · Signal Trace</span><span class="tag">Instrument rails</span></figcaption><img class="desktop" src="signal-desktop.png" alt="Signal Trace 데스크톱 전체 화면"/><div class="mobile-wrap"><img class="mobile" src="signal-mobile.png" alt="Signal Trace 모바일 전체 화면"/></div></figure>
<figure class="direction"><figcaption><span>03 · Field Manual</span><span class="tag">Numbered protocol</span></figcaption><img class="desktop" src="manual-desktop.png" alt="Field Manual 데스크톱 전체 화면"/><div class="mobile-wrap"><img class="mobile" src="manual-mobile.png" alt="Field Manual 모바일 전체 화면"/></div></figure>
</main>
<footer>dual-entry-shared-lab · tx-lost-update-01 · visual comparison only</footer>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

@@ -0,0 +1,119 @@
prototype-id: hyeonworks-ledger-core-flow-r4
direction-cycle-id: hyeonworks-vnext-direction-cycle-1
selected-direction-id: ledger-studio
selected-direction-ref: hyeonworks/completion-records/hyeonworks-vnext-v1-direction/DES-DIRECTOR-20260718T115933Z.report.yaml
selected-direction-sha256: 90b64c99493f1789a134459813207291dfa4beafa794e3f861b4f28d23286a65
revision: 4
revision-basis:
panel-ref: hyeonworks/completion-records/hyeonworks-vnext-v1-direction/DES-DIRECTOR-20260718T142500Z.report.yaml
panel-sha256: b381e71bfe662dd5b342e708131440c435a7a2fc82ff33a7fa20b5fbb220bc89
verdict: minor-revision
review-findings:
- {lens: visual-craft, report-sha256: 28bf4a9a8eb12766f2430d394ed2086111f9983433fbeb664f2a2462cce94688, ids: [VC-R3-01, VC-R3-02]}
- {lens: implementability, report-sha256: b02f9c89fb797411d16633b4112db2cbd91e783fbcfbcd90c21423cf079b2d61, ids: [IMP-R3-01]}
routes:
- {path: "#/", purpose: "Technology Atlas promise, two entries, one shared Lab"}
- {path: "#/orient/concept", purpose: "concept relation orientation"}
- {path: "#/orient/symptom", purpose: "symptom clue reveal without diagnosis claim"}
- {path: "#/lab/tx-lost-update-01", purpose: "shared causal-hypothesis learning loop"}
- {path: "#/lab/tx-lost-update-inventory-fixture", purpose: "direct-route-only registry/render binding verification fixture"}
shared-core:
scenario-registry: scenarioRegistry
default-scenario-id: tx-lost-update-01
active-scenario-selector: "route scenarioId → labState.scenarioId → currentScenario; all Lab render, dispatch and announcements receive that record"
verification-fixture:
id: tx-lost-update-inventory-fixture
exposure: direct-route-only
schedule-steps: 4
purpose: "default와 다른 atlas·50/60/40 values·copy·choice ids·answers·transfer·4-step schedule의 end-to-end binding 검증"
reducer: learningReducer
entry-invariant: "두 orientation은 같은 id 기반 Lab route와 같은 initialLabState로 합류"
deterministic-boundary: "Date.now, Math.random, network, storage 없이 registry/state/action으로만 전이"
fixed-learning-loop: [Predict, Observe, Compare, Explain, Transfer]
values: {initial: 100, serial-expected: 120, guided-observed: 70, a-write: 150, b-write: 70}
learning-evidence:
predict: "최종값 회상이 아니라 read/write 관계에 대한 원인 가설을 관찰 전에 고정"
observe: "native table로 6-step schedule을 한 단계씩 실행"
compare: "가설과 raw READ/WRITE/Delta evidence를 대조"
explain: "read basis, final write, lost change 세 요소를 각각 구성하고 명시적으로 제출"
transfer: "새 재고 사례에서 사라진 변화를 식별"
completion-boundary: "구성·전이 통과만 기록하며 자유 서술 능력·실제 장애 진단 효과는 주장하지 않음"
revision-closure:
- {source: product-fit, finding: "answer leakage before Predict", resolution: "cause hypothesis Predict + write order hidden until Observe"}
- {source: product-fit, finding: "recognition-only Explain", resolution: "three-part constructed causal task"}
- {source: usability, finding: "choice and Observe focus loss", resolution: "action-specific focus restoration and final-step handoff"}
- {source: usability, finding: "silent/stale feedback", resolution: "role=status, focused feedback, selection resets submitted state"}
- {source: usability, finding: "entry below mobile fold", resolution: "visible entry jump link under 900px"}
- {source: usability, finding: "trace semantics absent", resolution: "native table, thead/th scope, scroll region"}
- {source: visual-craft, finding: "Lab boundary contrast", resolution: "dedicated deep/on-deep tokens and single-column dark boundary"}
- {source: visual-craft, finding: "Korean syllable breaks", resolution: "keep-all typography and wider semantic line measures"}
- {source: visual-craft, finding: "tablet density collapse", resolution: "701900px two-column tablet layout and five-column progress"}
- {source: market-memorability, finding: "name/category hierarchy", resolution: "Technology Atlas canonical product name + developer mechanism Lab descriptor"}
- {source: systematizability, finding: "single-scenario coupling", resolution: "id registry, registry-driven values/copy/choices, explicit orientation state"}
- {source: systematizability, finding: "foundation recipe drift", resolution: "spacing/stroke/shadow/type tokens and shared sheet/ledger/value/progress recipes"}
- {source: implementability, finding: "unverified interaction states", resolution: "Chrome E2E for full loop, keyboard, live feedback, semantics, contrast and responsive contract"}
- {source: usability, finding: "UX-R2-U01 Observe widens 360 document", resolution: "minmax(0,1fr) mobile Lab track + workbench/trace containment; table alone scrolls internally"}
- {source: usability, finding: "UX-R2-U02 entry shortcut rerenders to hero", resolution: "local click handler focuses and scrolls tabindex=-1 entry-title without hash route mutation"}
- {source: visual-craft, finding: "VC-R2-01 Korean Lab words split at syllables", resolution: "Lab semantic body keep-all with anywhere fallback for unbroken Latin tokens; live Range assertions"}
- {source: systematizability, finding: "SYS-R2-01/02 default scenario render coupling", resolution: "scenario copy contract + explicit active scenario arguments + independent four-step inventory fixture browser flow"}
- {source: visual-craft, finding: "VC-R3-01 dark Lab small-label contrast", resolution: "accent-on-deep token gives 7.14:1 on ink and 5.62:1 on deep"}
- {source: visual-craft, finding: "VC-R3-02 pending text and essential control boundaries", resolution: "pending token gives 5.44:1 minimum including current-row peach; control-border gives 3.56:1 on sheet while decorative rule remains separate"}
- {source: implementability, finding: "IMP-R3-01 skip link invokes hash router and loses state", resolution: "single persistent local handler focuses/scrolls current tabindex=-1 main without hash mutation or rerender; route/scenario/phase/cursor matrix passes"}
- {source: visual-craft, finding: "adjacent light-surface coral contrast risk", resolution: "light coral token is tightened to #ad4031 while dark Lab retains accent-on-deep"}
implemented-invariants:
- LI-EDITORIAL-EVIDENCE
- LI-DUAL-ENTRY-SHARED-CORE
- LI-EVIDENCE-LEARNING-LOOP
- LI-HONEST-BOUNDARY
- LI-ACCESSIBLE-READING
files:
- path: hyeonworks/design-direction/hyeonworks-vnext-v1/prototype/dist/index.html
sha256: 6295b1cf4b5f44cd4e9a5242643712728709ac851d62c4587ef933830bd33861
- path: hyeonworks/design-direction/hyeonworks-vnext-v1/prototype/dist/styles.css
sha256: 71e51b907f5b01c480eb348df35db17995fc45269db36ed6a1afd113e3f29f58
- path: hyeonworks/design-direction/hyeonworks-vnext-v1/prototype/dist/app.js
sha256: b148057f4d25ba6afb4170c82f5c832263563feaf99a345a0d79e350d83fbed3
- path: hyeonworks/design-direction/hyeonworks-vnext-v1/prototype/scripts/verify_flow.cjs
sha256: a0910543e412b73cd5c7c87fefe3a871feb0c0308fac45fcefab42357218cfc8
preview-shots:
- {viewport: 360, path: hyeonworks/design-direction/hyeonworks-vnext-v1/prototype/preview.w360.png, sha256: db1af7ce77da4a9780a08ef4ad812485476f8ba5a6e3f3820a7c5d188356528a}
- {viewport: 768, path: hyeonworks/design-direction/hyeonworks-vnext-v1/prototype/preview.w768.png, sha256: 51ce61cff0ba2ddc9fd5dcd661f7c51e624f3d6ff3db000de0d4e6b57dfa5aee}
- {viewport: 1280, path: hyeonworks/design-direction/hyeonworks-vnext-v1/prototype/preview.w1280.png, sha256: f1b3918774289e2a2353ba4fc2d5deb6d6001777fef30ea83da7126df45cabd7}
route-previews:
- {state: concept, path: hyeonworks/design-direction/hyeonworks-vnext-v1/prototype/preview.state-concept.png, sha256: 778688488272718c02ae528f9be358f5a97fc7a0f2034487d992d9e4bd03c2c9}
- {state: symptom, path: hyeonworks/design-direction/hyeonworks-vnext-v1/prototype/preview.state-symptom.png, sha256: 08069f9de4f2e3290bf2643c55c9fcf24e54d0aa241732889b494f28328f8569}
- {state: predict, path: hyeonworks/design-direction/hyeonworks-vnext-v1/prototype/preview.state-lab.png, sha256: 530cdd1e9393e5374269e127c61b5882633b623791b713bdb5a592647d704007}
- {state: fixture, path: hyeonworks/design-direction/hyeonworks-vnext-v1/prototype/preview.state-fixture.png, sha256: afd27057f15a1b7ec6a57b6143f3cb355efe52880a736209ab2315a528bc7739}
interactive-state-previews:
directory: hyeonworks/design-direction/hyeonworks-vnext-v1/prototype/state-previews
states: [predict, observe, compare, explain, transfer, complete]
viewports: [360, 1280]
verification:
static-js: {command: "node --check dist/app.js", result: pass}
css-health: {command: "python3 .claude/hooks/preview_ui.py --contrast-only dist/styles.css", contrast: pass, focus-visible: pass}
dom-and-routes: {command: "preview_ui.py --no-build --viewports 360,768,1280 --states concept,symptom,lab,fixture", result: pass}
browser-e2e:
command: "node scripts/verify_flow.cjs"
result: pass
coverage: [dual-entry, shared-core, causal-predict, six-step-observe, contained-observe-scroll, entry-shortcut-focus-scroll, route-safe-skip-link, five-route-three-viewport-state-preservation, korean-word-integrity, latin-token-wrap, active-scenario-binding, four-step-second-fixture, constructed-explain, transfer, complete, reset, keyboard-focus, live-feedback, native-table, computed-runtime-contrast, mobile-overflow, tablet-density]
computed-contrast:
dark-lab-id-on-ink: 7.14
dark-boundary-label-on-deep: 5.62
pending-minimum-including-current-row: 5.44
choice-border-on-sheet: 3.56
textarea-border-on-sheet: 3.56
skip-link-matrix:
routes: [home, concept, symptom-with-revealed-clue, default-lab-observe-cursor-1, fixture-lab-observe-cursor-1]
viewports: [360, 768, 1280]
preserved: [main-node-identity, location-hash, scenario-id, phase, cursor, trace-state, clue-state]
aesthetic-claim: "4차 7-lens critique pass 전에는 최종 승인으로 주장하지 않음"
@@ -0,0 +1,9 @@
{
"name": "hyeonworks-ledger-prototype",
"private": true,
"version": "0.1.0",
"description": "Selected Ledger Studio core-flow prototype",
"scripts": {
"test:e2e": "node scripts/verify_flow.cjs"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

@@ -0,0 +1,445 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const http = require('node:http');
const path = require('node:path');
let puppeteer;
for (const candidate of [
'puppeteer-core',
process.env.PUPPETEER_CORE_PATH,
'/home/donghyeon/.bun/install/global/node_modules/puppeteer-core',
].filter(Boolean)) {
try {
puppeteer = require(candidate);
break;
} catch (_) {
// Try the next local harness installation. The product itself has no browser-test runtime dependency.
}
}
if (!puppeteer) throw new Error('puppeteer-core is required to run the local harness E2E');
const root = path.resolve(__dirname, '..', 'dist');
const stateShotDir = path.resolve(__dirname, '..', 'state-previews');
const mime = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8' };
function serverFor(directory) {
return http.createServer((request, response) => {
const requestPath = new URL(request.url, 'http://127.0.0.1').pathname;
const relative = requestPath === '/' ? 'index.html' : requestPath.replace(/^\/+/, '');
const file = path.resolve(directory, relative);
if (!file.startsWith(`${directory}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
response.writeHead(404).end('Not found');
return;
}
response.writeHead(200, { 'content-type': mime[path.extname(file)] || 'application/octet-stream' });
fs.createReadStream(file).pipe(response);
});
}
async function selectedAndFocused(page, selector) {
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => resolve())));
await page.focus(selector);
await page.keyboard.press('Space');
await page.waitForFunction(target => {
const element = document.querySelector(target);
return Boolean(element?.checked && document.activeElement === element);
}, {}, selector);
}
async function clickAndWait(page, selector, condition) {
await page.click(selector);
await page.waitForFunction(condition);
}
async function assertNoDocumentOverflow(page, label) {
const widths = await page.evaluate(() => ({
client: document.documentElement.clientWidth,
scroll: document.documentElement.scrollWidth,
}));
assert.equal(widths.scroll, widths.client, `${label}: document overflow ${widths.client} -> ${widths.scroll}`);
}
async function assertEntryShortcut(page, activation) {
if (activation === 'keyboard') {
await page.focus('[data-entry-jump]');
await page.keyboard.press('Enter');
} else {
await page.click('[data-entry-jump]');
}
await page.waitForFunction(() => {
const target = document.querySelector('#entry-title');
const top = target?.getBoundingClientRect().top;
return document.activeElement === target && top >= 0 && top <= 80;
});
const result = await page.evaluate(() => ({
activeId: document.activeElement?.id,
top: document.querySelector('#entry-title')?.getBoundingClientRect().top,
hash: location.hash,
}));
assert.equal(result.activeId, 'entry-title');
assert.ok(result.top >= 0 && result.top <= 80, `entry shortcut target top=${result.top}`);
assert.notEqual(result.hash, '#entry-title', 'local entry shortcut must not invoke the route renderer');
}
async function assertSkipLinkPreservesState(page, label) {
const main = await page.$('#main');
assert.ok(main, `${label}: current main must exist`);
const readState = element => ({
hash: location.hash,
scenarioId: element.dataset.scenarioId || null,
phase: element.dataset.phase || null,
cursor: element.dataset.cursor || null,
heading: element.querySelector('h1, h2')?.textContent.trim() || null,
trace: document.querySelector('.trace-note')?.textContent.trim() || null,
clues: document.querySelector('.clues')?.textContent.trim() || null,
});
const before = await page.evaluate(readState, main);
await page.focus('.skip-link');
await page.keyboard.press('Enter');
await page.waitForFunction(() => document.activeElement === document.querySelector('#main'));
const after = await page.evaluate((element, readerSource) => {
const reader = new Function(`return (${readerSource})`)();
return {
state: reader(element),
sameNode: element === document.querySelector('#main'),
active: document.activeElement === element,
tabIndex: element.tabIndex,
top: element.getBoundingClientRect().top,
viewportHeight: innerHeight,
};
}, main, readState.toString());
assert.deepEqual(after.state, before, `${label}: skip link must preserve route and learning state`);
assert.equal(after.sameNode, true, `${label}: skip link must not rerender the route`);
assert.equal(after.active, true, `${label}: current main must receive focus`);
assert.equal(after.tabIndex, -1, `${label}: current main must be programmatically focusable`);
assert.ok(after.top >= -8 && after.top < after.viewportHeight, `${label}: current main must be in view (top=${after.top})`);
await assertNoDocumentOverflow(page, `${label}:skip-link`);
await main.dispose();
}
async function assertSkipLinkAcrossViewports(page, label) {
const original = page.viewport();
for (const width of [360, 768, 1280]) {
await page.setViewport({ width, height: width === 360 ? 800 : 900, deviceScaleFactor: 1 });
await assertSkipLinkPreservesState(page, `${label}@${width}`);
}
await page.setViewport(original);
}
async function assertObserveContainment(page, label) {
const geometry = await page.evaluate(() => {
const workbench = document.querySelector('.workbench');
const trace = document.querySelector('.trace-scroll');
const action = document.querySelector('[data-action="advance"], [data-action="to-compare"]');
trace.scrollLeft = trace.scrollWidth;
const result = {
viewport: document.documentElement.clientWidth,
documentScroll: document.documentElement.scrollWidth,
workbenchRight: workbench.getBoundingClientRect().right,
actionRight: action.getBoundingClientRect().right,
traceClient: trace.clientWidth,
traceScroll: trace.scrollWidth,
traceScrollLeft: trace.scrollLeft,
};
trace.scrollLeft = 0;
return result;
});
assert.equal(geometry.documentScroll, geometry.viewport, `${label}: Observe must not widen the document`);
assert.ok(geometry.workbenchRight <= geometry.viewport + .5, `${label}: workbench right=${geometry.workbenchRight}`);
assert.ok(geometry.actionRight <= geometry.viewport + .5, `${label}: action right=${geometry.actionRight}`);
assert.ok(geometry.traceScroll > geometry.traceClient, `${label}: table overflow must remain inside trace-scroll`);
assert.ok(geometry.traceScrollLeft > 0, `${label}: trace-scroll must be horizontally operable`);
}
async function assertKoreanWordIntegrity(page, label) {
const result = await page.evaluate(() => {
const selectors = '.lab-brief > p, .workbench .lead, .choice span, .boundary p, .principle p, .feedback';
const elements = [...document.querySelectorAll(selectors)];
const broken = [];
const segmenter = new Intl.Segmenter('ko', { granularity: 'word' });
for (const element of elements) {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
for (const segment of segmenter.segment(node.data)) {
if (!segment.isWordLike || !/[가-힣]/.test(segment.segment)) continue;
const range = document.createRange();
range.setStart(node, segment.index);
range.setEnd(node, segment.index + segment.segment.length);
const tops = [...range.getClientRects()].map(rect => Math.round(rect.top));
if (new Set(tops).size > 1) broken.push(segment.segment);
}
}
}
const sample = document.querySelector('.workbench .lead') || document.querySelector('.lab-brief > p');
const style = sample ? getComputedStyle(sample) : null;
return { broken, wordBreak: style?.wordBreak, overflowWrap: style?.overflowWrap };
});
assert.deepEqual(result.broken, [], `${label}: Korean words split across lines: ${result.broken.join(', ')}`);
assert.equal(result.wordBreak, 'keep-all', `${label}: Lab body must use keep-all`);
assert.equal(result.overflowWrap, 'anywhere', `${label}: long Latin tokens need a safe fallback`);
}
async function captureState(page, name) {
fs.mkdirSync(stateShotDir, { recursive: true });
for (const width of [360, 1280]) {
await page.setViewport({ width, height: 900, deviceScaleFactor: 1 });
if (width === 360) {
await assertNoDocumentOverflow(page, `${name}@360`);
await assertKoreanWordIntegrity(page, `${name}@360`);
}
await page.screenshot({ path: path.join(stateShotDir, `${name}.w${width}.png`), fullPage: true });
}
await page.setViewport({ width: 360, height: 800, deviceScaleFactor: 1 });
}
function contrastRatio(foreground, background) {
const rgb = value => value.match(/[\d.]+/g).slice(0, 3).map(Number);
const luminance = value => {
const [red, green, blue] = rgb(value).map(channel => {
const normalized = channel / 255;
return normalized <= .03928 ? normalized / 12.92 : ((normalized + .055) / 1.055) ** 2.4;
});
return .2126 * red + .7152 * green + .0722 * blue;
};
const first = luminance(foreground);
const second = luminance(background);
return (Math.max(first, second) + .05) / (Math.min(first, second) + .05);
}
async function assertContrastContract(page, label, expected = {}) {
const colors = await page.evaluate(() => {
const effectiveBackground = element => {
for (let current = element; current; current = current.parentElement) {
const background = getComputedStyle(current).backgroundColor;
if (background && background !== 'rgba(0, 0, 0, 0)') return background;
}
return getComputedStyle(document.body).backgroundColor;
};
const textSample = element => ({
foreground: getComputedStyle(element).color,
background: effectiveBackground(element),
});
const borderSample = element => ({
foreground: getComputedStyle(element).borderTopColor,
background: effectiveBackground(element),
});
return {
labId: textSample(document.querySelector('.lab-brief .lab-id')),
boundaryLabel: textSample(document.querySelector('.lab-brief .boundary b')),
pending: [...document.querySelectorAll('.trace-event.pending')].map(textSample),
choices: [...document.querySelectorAll('.choice')].map(borderSample),
textareas: [...document.querySelectorAll('textarea')].map(borderSample),
};
});
const ratio = sample => contrastRatio(sample.foreground, sample.background);
const metrics = {
labId: ratio(colors.labId),
boundaryLabel: ratio(colors.boundaryLabel),
pendingMin: colors.pending.length ? Math.min(...colors.pending.map(ratio)) : null,
choiceMin: colors.choices.length ? Math.min(...colors.choices.map(ratio)) : null,
textareaMin: colors.textareas.length ? Math.min(...colors.textareas.map(ratio)) : null,
};
assert.ok(metrics.labId >= 4.5, `${label}: Lab id contrast ${metrics.labId.toFixed(2)} < 4.5`);
assert.ok(metrics.boundaryLabel >= 4.5, `${label}: dark boundary label contrast ${metrics.boundaryLabel.toFixed(2)} < 4.5`);
if (expected.pending) {
assert.ok(metrics.pendingMin !== null && metrics.pendingMin >= 4.5, `${label}: pending text contrast ${metrics.pendingMin?.toFixed(2)} < 4.5`);
}
if (expected.choices) {
assert.ok(metrics.choiceMin !== null && metrics.choiceMin >= 3, `${label}: choice border contrast ${metrics.choiceMin?.toFixed(2)} < 3`);
}
if (expected.textarea) {
assert.ok(metrics.textareaMin !== null && metrics.textareaMin >= 3, `${label}: textarea border contrast ${metrics.textareaMin?.toFixed(2)} < 3`);
}
return metrics;
}
(async () => {
const server = serverFor(root);
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const port = server.address().port;
const browser = await puppeteer.launch({
executablePath: process.env.CHROME_BIN || '/usr/bin/google-chrome',
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
const runtimeErrors = [];
page.on('pageerror', error => runtimeErrors.push(error.message));
try {
const contrastEvidence = {};
await page.setViewport({ width: 360, height: 800, deviceScaleFactor: 1 });
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'networkidle0' });
const mobileHealth = await page.evaluate(() => ({
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
jumpTop: document.querySelector('.entry-jump').getBoundingClientRect().top,
jumpVisible: getComputedStyle(document.querySelector('.entry-jump')).display !== 'none',
}));
assert.ok(mobileHealth.overflow <= 0, `mobile horizontal overflow=${mobileHealth.overflow}`);
assert.equal(mobileHealth.jumpVisible, true);
assert.ok(mobileHealth.jumpTop < 800, `entry jump is below first viewport: ${mobileHealth.jumpTop}`);
await assertEntryShortcut(page, 'keyboard');
await page.setViewport({ width: 768, height: 900, deviceScaleFactor: 1 });
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'networkidle0' });
const tabletColumns = await page.$eval('.hero', element => getComputedStyle(element).gridTemplateColumns.split(' ').length);
assert.equal(tabletColumns, 2, 'tablet hero must retain two-column editorial density');
await assertEntryShortcut(page, 'pointer');
await assertSkipLinkAcrossViewports(page, 'home');
await page.setViewport({ width: 360, height: 800, deviceScaleFactor: 1 });
await page.goto(`http://127.0.0.1:${port}/#/orient/concept`, { waitUntil: 'networkidle0' });
await assertSkipLinkAcrossViewports(page, 'concept');
await page.goto(`http://127.0.0.1:${port}/#/orient/symptom`, { waitUntil: 'networkidle0' });
await page.click('[data-reveal]');
await page.waitForFunction(() => /2 \/ 4/.test(document.querySelector('.folio-head')?.textContent || ''));
await assertSkipLinkAcrossViewports(page, 'symptom-with-revealed-clue');
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'networkidle0' });
await page.click('a[href="#/orient/concept"]');
await page.waitForSelector('a[data-start-lab]');
await page.click('a[data-start-lab]');
await page.waitForSelector('input[name="prediction"]');
assert.equal(await page.$$eval('.progress-item', items => items.length), 5);
contrastEvidence.predict = await assertContrastContract(page, 'default-predict', { choices: true });
const hypothesis = 'input[name="prediction"][value="shared-stale-read"]';
await selectedAndFocused(page, hypothesis);
assert.equal(await page.$eval('[data-action="commit-prediction"]', button => button.disabled), false);
await clickAndWait(page, '[data-action="commit-prediction"]', () => document.querySelector('[data-action="advance"]'));
await assertObserveContainment(page, 'default@360');
await page.click('[data-action="advance"]');
await page.waitForFunction(() => document.activeElement?.dataset?.action === 'advance' && /실행 1\/6/.test(document.querySelector('#announcer').textContent));
await assertSkipLinkAcrossViewports(page, 'default-observe-cursor-1');
contrastEvidence.observe = await assertContrastContract(page, 'default-observe-cursor-1', { pending: true });
await captureState(page, 'observe');
for (let step = 1; step < 6; step += 1) {
await page.click('[data-action="advance"]');
const expectedAction = step === 5 ? 'to-compare' : 'advance';
await page.waitForFunction(action => document.activeElement?.dataset?.action === action, {}, expectedAction);
}
assert.equal(await page.$$eval('.trace-table thead th[scope="col"]', headers => headers.length), 4);
await clickAndWait(page, '[data-action="to-compare"]', () => document.querySelector('[data-action="to-explain"]'));
await captureState(page, 'compare');
await clickAndWait(page, '[data-action="to-explain"]', () => document.querySelector('[data-action="submit-explanation"]'));
await captureState(page, 'explain');
contrastEvidence.explain = await assertContrastContract(page, 'default-explain', { choices: true, textarea: true });
await selectedAndFocused(page, 'input[name="explanation-readBasis"][value="same-100"]');
await selectedAndFocused(page, 'input[name="explanation-finalWrite"][value="a-150"]');
await selectedAndFocused(page, 'input[name="explanation-lostChange"][value="a-plus-50"]');
await page.click('[data-action="submit-explanation"]');
await page.waitForFunction(() => document.activeElement?.id === 'explanation-feedback');
assert.match(await page.$eval('#explanation-feedback', element => element.textContent), /맞지 않습니다/);
assert.equal(await page.$('[data-action="to-transfer"]'), null);
await selectedAndFocused(page, 'input[name="explanation-finalWrite"][value="b-70"]');
assert.equal(await page.$('#explanation-feedback'), null, 'changed answer must reset submitted feedback');
await page.click('[data-action="submit-explanation"]');
await page.waitForFunction(() => document.activeElement?.id === 'explanation-feedback');
assert.ok(await page.$('[data-action="to-transfer"]'));
await clickAndWait(page, '[data-action="to-transfer"]', () => document.querySelector('[data-action="submit-transfer"]'));
await captureState(page, 'transfer');
await selectedAndFocused(page, 'input[name="transfer"][value="lost-restock"]');
await page.click('[data-action="submit-transfer"]');
await page.waitForFunction(() => document.activeElement?.id === 'transfer-feedback');
assert.match(await page.$eval('#transfer-feedback', element => element.textContent), /다시 비교하세요/);
assert.equal(await page.$('[data-action="complete"]'), null);
await selectedAndFocused(page, 'input[name="transfer"][value="lost-reservation"]');
assert.equal(await page.$('#transfer-feedback'), null, 'changed transfer must reset submitted feedback');
await page.click('[data-action="submit-transfer"]');
await page.waitForFunction(() => document.activeElement?.id === 'transfer-feedback');
await clickAndWait(page, '[data-action="complete"]', () => document.querySelector('.completion'));
await captureState(page, 'complete');
contrastEvidence.complete = await assertContrastContract(page, 'default-complete');
assert.match(await page.$eval('.completion h2', element => element.textContent), /세 인과 요소를 연결/);
assert.match(await page.$eval('.completion .lead', element => element.textContent), /자유 서술 능력이나 실제 장애 진단을 증명하지 않습니다/);
await clickAndWait(page, '[data-action="reset"]', () => document.querySelector('input[name="prediction"]'));
await captureState(page, 'predict');
assert.equal(await page.$eval('.lab-brief .value.observed b', element => element.textContent), '100');
const boundaryColors = await page.$eval('.lab-brief .boundary p', element => {
const style = getComputedStyle(element);
const background = getComputedStyle(element.closest('.boundary')).backgroundColor;
return { foreground: style.color, background };
});
assert.ok(contrastRatio(boundaryColors.foreground, boundaryColors.background) >= 4.5, 'Lab boundary contrast must be AA');
await page.goto(`http://127.0.0.1:${port}/#/orient/symptom`, { waitUntil: 'networkidle0' });
for (let clue = 1; clue < 4; clue += 1) await page.click('[data-reveal]');
await page.waitForSelector('a[data-start-lab]');
await page.click('a[data-start-lab]');
await page.waitForSelector('input[name="prediction"]');
assert.equal(await page.$eval('main[data-scenario-id]', element => element.dataset.scenarioId), 'tx-lost-update-01');
await page.setViewport({ width: 360, height: 800, deviceScaleFactor: 1 });
await page.goto(`http://127.0.0.1:${port}/#/lab/tx-lost-update-inventory-fixture`, { waitUntil: 'networkidle0' });
await page.waitForSelector('input[name="prediction"]');
assert.equal(await page.$eval('main[data-scenario-id]', element => element.dataset.scenarioId), 'tx-lost-update-inventory-fixture');
assert.match(await page.$eval('.lab-top .lab-id', element => element.textContent), /Inventory fixture/);
assert.deepEqual(await page.$$eval('.lab-brief .value b', elements => elements.map(element => element.textContent)), ['50', '60', '50']);
assert.match(await page.$eval('.choice span', element => element.textContent), /같은 50/);
const fixtureTokenWrap = await page.$eval('.lab-brief > p', element => ({ client: element.clientWidth, scroll: element.scrollWidth }));
assert.ok(fixtureTokenWrap.scroll <= fixtureTokenWrap.client, `fixture Latin token overflow ${fixtureTokenWrap.client} -> ${fixtureTokenWrap.scroll}`);
await assertNoDocumentOverflow(page, 'fixture-predict@360');
await assertKoreanWordIntegrity(page, 'fixture-predict@360');
await selectedAndFocused(page, 'input[name="prediction"][value="fixture-shared-50"]');
await clickAndWait(page, '[data-action="commit-prediction"]', () => document.querySelector('[data-action="advance"]'));
assert.equal(await page.$$eval('.trace-table tbody tr', rows => rows.length), 4, 'fixture schedule must not inherit the default six rows');
assert.match(await page.$eval('.trace-scroll', element => element.getAttribute('aria-label')), /Inventory fixture/);
await assertObserveContainment(page, 'fixture@360');
await page.click('[data-action="advance"]');
await page.waitForFunction(() => document.activeElement?.dataset?.action === 'advance' && /실행 1\/4/.test(document.querySelector('#announcer').textContent));
assert.match(await page.$eval('#announcer', element => element.textContent), /A가 재고 50/);
await assertSkipLinkAcrossViewports(page, 'fixture-observe-cursor-1');
contrastEvidence.fixtureObserve = await assertContrastContract(page, 'fixture-observe-cursor-1', { pending: true });
for (let step = 1; step < 4; step += 1) {
await page.click('[data-action="advance"]');
const expectedAction = step === 3 ? 'to-compare' : 'advance';
await page.waitForFunction(action => document.activeElement?.dataset?.action === action, {}, expectedAction);
}
await clickAndWait(page, '[data-action="to-compare"]', () => document.querySelector('[data-action="to-explain"]'));
const fixtureEvidence = await page.$eval('.causal-ledger', element => element.textContent);
assert.match(fixtureEvidence, /A=70 다음 B=40/);
assert.doesNotMatch(fixtureEvidence, /A=150/);
await clickAndWait(page, '[data-action="to-explain"]', () => document.querySelector('[data-action="submit-explanation"]'));
await selectedAndFocused(page, 'input[name="explanation-readBasis"][value="same-50"]');
await selectedAndFocused(page, 'input[name="explanation-finalWrite"][value="b-40"]');
await selectedAndFocused(page, 'input[name="explanation-lostChange"][value="a-plus-20"]');
await page.click('[data-action="submit-explanation"]');
await page.waitForFunction(() => document.activeElement?.id === 'explanation-feedback');
assert.match(await page.$eval('#explanation-feedback', element => element.textContent), /Fixture 인과/);
await clickAndWait(page, '[data-action="to-transfer"]', () => document.querySelector('[data-action="submit-transfer"]'));
assert.deepEqual(await page.$$eval('.value-comparison b', elements => elements.map(element => element.textContent)), ['100', '110', '115']);
await selectedAndFocused(page, 'input[name="transfer"][value="lost-use"]');
await page.click('[data-action="submit-transfer"]');
await page.waitForFunction(() => document.activeElement?.id === 'transfer-feedback');
assert.match(await page.$eval('#transfer-feedback', element => element.textContent), /Fixture 전이 성공/);
await clickAndWait(page, '[data-action="complete"]', () => document.querySelector('.completion'));
assert.match(await page.$eval('.completion h2', element => element.textContent), /Fixture의 세 인과 요소/);
assert.match(await page.$eval('.principle b', element => element.textContent), /원자적 재고 변경/);
await clickAndWait(page, '[data-action="reset"]', () => document.querySelector('input[name="prediction"]'));
assert.equal(await page.$eval('.lab-brief .value.observed b', element => element.textContent), '50');
assert.equal(await page.$eval('main[data-scenario-id]', element => element.dataset.scenarioId), 'tx-lost-update-inventory-fixture');
assert.deepEqual(runtimeErrors, []);
console.log(`PASS contrast metrics ${JSON.stringify(contrastEvidence)}`);
console.log('PASS prototype E2E: dual-entry, contained Observe, entry focus, route-safe skip link, Korean type, scenario binding, causal loop, semantics and contrast');
} finally {
await browser.close();
await new Promise(resolve => server.close(resolve));
}
})().catch(error => {
console.error(error.stack || error);
process.exitCode = 1;
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Deterministic local checks for the three design-direction coded slices."""
from __future__ import annotations
import hashlib
import json
import struct
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DIRECTIONS = {
"ledger-studio": ("ledger-desktop.png", "ledger-mobile.png"),
"signal-trace": ("signal-desktop.png", "signal-mobile.png"),
"field-manual": ("manual-desktop.png", "manual-mobile.png"),
}
REQUIRED = (
"개념을 알고 있어요",
"증상만 알고 있어요",
"Predict",
"Observe",
"Compare",
"Explain",
"Transfer",
)
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def png_size(path: Path) -> tuple[int, int]:
raw = path.read_bytes()[:24]
if len(raw) != 24 or raw[:8] != b"\x89PNG\r\n\x1a\n" or raw[12:16] != b"IHDR":
raise AssertionError(f"not a PNG: {path}")
return struct.unpack(">II", raw[16:24])
def main() -> None:
records = []
previews = ROOT / "previews"
for direction, (desktop_name, mobile_name) in DIRECTIONS.items():
folder = ROOT / "directions" / direction
html = folder / "index.html"
concept = folder / "concept.yaml"
assert html.is_file(), html
assert concept.is_file(), concept
source = html.read_text(encoding="utf-8")
for marker in REQUIRED:
assert marker in source, f"{direction}: missing {marker}"
assert "focus-visible" in source, f"{direction}: focus style missing"
assert "prefers-reduced-motion" in source, f"{direction}: reduced-motion missing"
assert "http://" not in source and "https://" not in source, f"{direction}: external dependency"
desktop = previews / desktop_name
mobile = previews / mobile_name
assert png_size(desktop) == (1280, 1100), f"{desktop}: wrong dimensions"
assert png_size(mobile) == (390, 844), f"{mobile}: wrong dimensions"
records.append({
"direction": direction,
"coded_slice_sha256": sha256(html),
"concept_sha256": sha256(concept),
"desktop_sha256": sha256(desktop),
"mobile_sha256": sha256(mobile),
})
gallery = previews / "comparison.png"
assert gallery.is_file(), gallery
width, height = png_size(gallery)
assert width >= 1500 and height >= 1000, "comparison preview is not full-size"
print(json.dumps({
"status": "pass",
"representative_screen": "dual-entry-shared-lab",
"directions": records,
"comparison": {"sha256": sha256(gallery), "size": [width, height]},
}, ensure_ascii=False, sort_keys=True))
if __name__ == "__main__":
main()