init: company-haness 설계
@@ -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 선택 다음에 01–05 공통 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 48–72px; 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: Predict–Transfer 다섯 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 순서는 01–05를 유지한다."
|
||||
mobile:
|
||||
rule: "360–390px에서 identification → 01A → 01B → merge → protocol 01–05 → 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 48–88px; mobile 36–52px; line-height 0.9–1.0"
|
||||
constraint: "설명 문장, 교육 범위, roadmap에는 대형 condensed display를 사용하지 않는다."
|
||||
instruction:
|
||||
role: "한국어 제목, 행동 지시, 설명, boundary copy"
|
||||
voice: "warm humanist sans"
|
||||
scale: "body 16–18px / 1.55–1.7; action label 18–24px / 1.25"
|
||||
constraint: "한 checkpoint의 핵심 지시는 한 문장, 보조 설명은 두 문장을 넘기지 않는다."
|
||||
command:
|
||||
role: "TX-LOST-UPDATE-01, session, read/compute/write, 100→150/70 값"
|
||||
voice: "monospace with tabular numerals"
|
||||
scale: "13–16px / 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: "01–05 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">FM–001 · 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: "360–780px"
|
||||
rules:
|
||||
- "hero, entry, loop, boundary를 DOM 순서 그대로 한 열로 적층한다."
|
||||
- "420px 이하에서 masthead를 두 줄로 쌓고 ledger value를 세 행으로 바꿔 가로 넘침을 막는다."
|
||||
- "merge label, trace row, footer는 줄바꿈을 허용하되 의미 순서는 유지한다."
|
||||
spacing:
|
||||
base: "8px 계열의 여백을 쓰되 hero와 section 사이에는 32–80px의 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>
|
||||
|
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>
|
||||
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
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: "701–900px 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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
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;
|
||||
});
|
||||
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
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()
|
||||