디자인 시스템 1.0 2026년 9월 기준

연구대장의 모든 화면은
이 기준으로 만들어요

색·글자·간격·움직임을 값으로 정해 두었어요. 헤더부터 푸터, 창과 토스트까지 실제로 눌러 보며 검토할 수 있어요.

컴포넌트 보기
강조색
파랑 262°
기준 폭
360 · 1280px
글꼴
Pretendard
테마
라이트 · 다크

01브랜드

로고는 크기만 골라서 써요

펼친 연구노트 모양의 심볼과 글자 로고 '연구대장'을 부분 템플릿 하나로 그려요. 크기를 고르면 글자 높이·간격·다크 모드 색이 함께 따라와요.

가로형 로고

심볼과 글자 로고를 붙여 쓰는 기본형이에요. size 는 심볼 높이(px)이고, 글자 높이(0.72배)와 간격(0.22배)은 비율로 따라와요.

  • 20px · 화면 최소 표·목록 행 안, 좁은 머리글
  • 28px · 기본 상단 내비(푸터는 26)
  • 40px · 크게 로그인·오류·빈 화면
  • 56px · 가장 크게 첫 화면, 공유 이미지
ERB
<%# 기본 28px. size 는 심볼 높이(px). 글자 높이·간격은 components.css 가 비율로 계산해요. %>
<%= render "shared/brand", size: 28 %>

<%# 로고만으로 어디인지 알려야 하는 곳은 읽어 줄 이름을 줘요. 곁에 이름이 있으면 비워 둬요(장식). %>
<%= link_to site_home_path, class: "nav__brand", "aria-label": "#{brand(:name)} 홈" do %>
  <%= render "shared/brand", size: 28 %>
<% end %>

<%= render "shared/brand", size: 56, label: brand(:name) %>

심볼만

자리가 좁거나 이름이 곁에 이미 있을 때 써요. wordmark: false 로 글자 로고를 빼요.

28px 40px 56px
ERB
<%= render "shared/brand", size: 40, wordmark: false, label: brand(:name) %>

글자 로고만

심볼이 바로 옆에 따로 보이는 자리에서만 써요. .brand 안에 .brand__word 하나만 둬요.

높이 14px 높이 23px
ERB
<%# 글자 로고만: .brand 가 크기(--brand-h, 심볼 높이 기준)를 정하고 .brand__word 가 그 0.72배 높이로 그려져요. %>
<span class="brand" style="--brand-h:32px" role="img" aria-label="<%= brand(:name) %>">
  <span class="brand__word"></span>
</span>

밝은 면과 어두운 면

글자 로고는 --brand-word-1·2 로 칠해서 어두운 면에서는 밝은 글자로 뒤집혀요. 심볼은 색이 박힌 그림이라 두 면에서 같아요.

밝은 면 · --brand-word-1 #0038A9
어두운 면 · --brand-word-1 #FFFFFF
ERB
<%# 어두운 면: 그 면에 data-theme="dark" 를 붙이면 안쪽이 다크 토큰을 받아 글자 로고가 밝게 뒤집혀요.
    반전 면(.surface-invert)은 다크 모드에서 밝은 면이 되므로 로고를 얹을 어두운 면으로 쓰지 않아요. %>
<section class="section surface-base" data-theme="dark">
  <div class="container">
    <%= render "shared/brand", size: 40, label: brand(:name) %>
  </div>
</section>

여백과 최소 크기

로고 둘레에는 심볼 높이(H)의 절반만큼 비워 둬요. 다른 글자·아이콘·면의 가장자리가 점선 안으로 들어오지 않게 해요.

로고 둘레의 여백은 심볼 높이의 절반이에요.

화면 최소
심볼 20px더 작은 자리는 심볼만 쓰거나 파비콘을 써요
인쇄 최소
심볼 높이 6mm더 작으면 글자 로고를 빼고 심볼만 써요
여백
사방 0.5H자리가 모자라면 로고를 줄이지 말고 자리를 넓혀요

여백과 인쇄 크기는 이 화면에서 처음 정한 기준이에요. 인쇄 시안으로 확인한 뒤 확정해요(화면 20px 은 인쇄로 약 5.3mm).

움직이는 심볼

로고가 주인공인 첫 화면에서 한 번 펼쳐져요. 뒤표지, 속지, 앞표지, 줄 순서로 서고 그다음 천천히 떠 있어요.

모션 줄이기 설정이면 움직이지 않고 완성된 모양으로 보여요.

ERB
<%# 로고가 주인공인 첫 화면에서만. 내비·푸터의 작은 로고는 shared/_brand 를 써요.
    부를 때마다 그라데이션 id 에 접두사를 바꿔 붙여 한 화면에 여러 번 써도 겹치지 않아요. %>
<%= brand_mark_svg(animated: true, class: "sg-hero__mark") %>

<%# 뜻이 있는 그림이면 읽어 줄 이름을 줘요(기본은 장식, aria-hidden). %>
<%= brand_mark_svg(label: brand(:name)) %>

파비콘과 앱 아이콘

브라우저 탭·홈 화면·설치 목록에 나가는 그림 파일이에요. 머리말(shared/brand_head)이 ds_brand.rb 의 경로를 읽어 붙여요.

  • favicon-16 아이콘(16×16) favicon-16 16×16 · 탭·북마크
  • favicon-32 아이콘(32×32) favicon-32 32×32 · 고해상도 탭
  • apple-touch-icon 아이콘(180×180) apple-touch-icon 180×180 · iPhone 홈 화면(불투명)
  • icon-192 아이콘(192×192) icon-192 192×192 · 앱 설치 목록
  • icon-maskable-512 아이콘(512×512) icon-maskable-512 512×512 · 안드로이드 둥근 마스크

마스크 아이콘은 원형으로 잘라 보였어요. 마크가 가운데 80% 안에 있어야 잘리지 않아요. 이 밖에 icon.svg(최신 브라우저 탭)와 icon-512.png 가 있어요.

ERB
<%# config/initializers/ds_brand.rb %>
icon_svg: "brand/icon.svg",                    # 최신 브라우저 탭(sizes="any")
icon_png: "brand/favicon-32.png",              # 탭·북마크
apple_touch_icon: "brand/apple-touch-icon.png", # 180×180, 불투명
manifest: "/manifest.json",                    # 설치 이름·icon-192·icon-512·maskable
og_image: "brand/og.png",                      # 1200×630 공유 카드

<%# 레이아웃 머리말에서 한 줄로 전부 붙어요 %>
<%= render "shared/brand_head" %>

브랜드 색

로고의 파랑과 화면의 강조색은 같은 색상각(OKLCH 262° 근처)에서 나와 나란히 놓아도 따로 놀지 않아요. 화면 글자·버튼에는 로고 색이 아니라 --accent 를 써요.

심볼 파랑 #0763FF → #003ED6 → #021C90 그림에 박힌 색이에요. 두 테마에서 같아요
글자 로고 색 #0038A9 → #021747 라이트는 남색, 다크는 밝은 파랑이에요
화면 강조 #246BF8 강조 램프 600, 다크 400

심볼 파랑 #0763FF 는 흰 바탕 대비 4.96:1 이지만 어두운 바탕(#0B1118)에서는 3.83:1 이라 글자로 쓰면 모자라요. 화면 강조는 다크에서 한 단계 밝은 400(6.00:1)으로 바뀌어요.

이름 표기

화면 글자는 한 가지 표기만 써요. 값은 config/initializers/ds_brand.rb 의 name·name_en·domain 에서 읽어요.

화면 글자
연구대장연구대장에서 서명했어요
영문·파일
rndrecord모두 소문자로 붙여 써요
주소
rndrecord.com공유 카드·메일 서명
문자 머리말
[연구대장][연구대장] 김지수 연구원이 검토를 요청했어요
쓰지 않아요
연구 대장 · RnDRecord · 연구대장 앱띄어 쓰거나 대문자를 섞거나 말을 덧붙이지 않아요

하지 말 것

로고는 부분 템플릿 그대로 써요. 아래처럼 바꾸면 다른 서비스처럼 보여요.

  • 그대로 쓰기
  • 늘리거나 누르기
  • 색 바꾸기
  • 그림자 넣기
  • 기울이기
  • 다른 글꼴로 다시 쓰기

02색

색은 이름(역할)으로 골라요

화면은 --ink·--surface·--accent 같은 역할 토큰만 써요. 역할 토큰이 테마에 맞는 값을 가져와서 다크 모드가 저절로 따라와요. 견본을 누르면 토큰 이름이 복사돼요.

강조 램프

로고 파랑에 맞춘 색상각 262° 한 가지로 11단계를 만들었어요. 주버튼은 600, 링크는 700 이에요. 숫자는 흰 바탕 대비예요.

역할 토큰이 쓰는 단계

  • 라이트 accent-600 · 다크 accent-400
  • 라이트 accent-700 · 다크 accent-300
  • 라이트 accent-800 · 다크 accent-500
  • 라이트 accent-700 · 다크 accent-300
  • 라이트 accent-50 · 다크 accent-400 · 14%
  • 라이트 accent-200 · 다크 accent-400 · 30%
  • 라이트 accent-500 · 다크 accent-400

중립 램프

아주 옅은 푸른기(256°)를 섞은 회색 13단계예요. 순회색은 화면에서 탁하게 읽혀요. 본문 글자는 800, 제목은 950 이에요.

역할 토큰이 쓰는 단계

  • 라이트 neutral-0 · 다크 neutral-1000
  • 라이트 neutral-50 · 다크 #12181F
  • 라이트 neutral-200 · 다크 neutral-0 · 9%
  • 라이트 neutral-300 · 다크 neutral-0 · 16%
  • 라이트 neutral-400 · 다크 #575E69
  • 라이트 neutral-600 · 다크 #7C848F
  • 라이트 neutral-700 · 다크 #9AA2AD
  • 라이트 neutral-800 · 다크 #D7DBE1
  • 라이트 neutral-950 · 다크 #F4F6F8

다크 값은 따로 정한 푸른 회색이라 램프에 없는 색이 섞여 있어요. 그래서 원시 램프를 쓰면 다크에서 뒤집히지 않아요.

보조색

상태를 알릴 때만 써요. 채도를 강조색보다 낮춰 강조색이 계속 주인공이게 했어요. 장식에 쓰면 상태가 읽히지 않아요.

초록 · 완료·서명--green-*

green-800green-50green-500

호박 · 주의·대기--amber-*

amber-900amber-50amber-500

빨강 · 오류·위험--red-*

red-700red-50red-500

보라 · 보조 강조--violet-*

violet-800violet-50

역할 토큰

화면에 쓰는 이름이에요. 오른쪽 값은 지금 테마에서 브라우저가 읽은 실제 값이고, 테마를 바꾸면 바로 다시 읽어요.

지금 라이트 테마 값이에요

  • 화면 바탕 #FFFFFF 라이트 neutral-0 · 다크 neutral-1000
  • 한 단계 들어간 면. 섹션 구분 #F9FAFB 라이트 neutral-50 · 다크 #12181F
  • 더 들어간 면. 입력 호버 #F3F5F7 라이트 neutral-100 · 다크 #0A0F15
  • 카드·시트·메뉴의 면 #FFFFFF 라이트 neutral-0 · 다크 #151C24
  • 면 위의 면 #F9FAFB 라이트 neutral-50 · 다크 #1B232D
  • 틴트 줄 위로 올라온 칸(분절의 고른 칸) #FFFFFF 라이트 neutral-0 · 다크 #2A3340
  • 반전 섹션 #171D26 라이트 neutral-950 · 다크 neutral-50
CSS
/* screens/<화면>.css: 역할 토큰만 써요. 다크 모드는 토큰이 알아서 바꿔요. */
.note-card{
  background:var(--surface);
  color:var(--ink-body);
  border-radius:var(--r-xl);
  box-shadow:var(--sh-sm);            /* 테두리와 그림자는 둘 중 하나만 */
}
.note-card__title{color:var(--ink)}
.note-card__meta{color:var(--ink-muted)}
.note-card__link{color:var(--accent-text)}
.note-card--signed{background:var(--good-soft);color:var(--good-text)}

/* 안 돼요: 원시 색·원시 램프는 다크에서 뒤집히지 않아요 */
/* color:#3F4752;  background:var(--neutral-50); */

라이트·다크 나란히

어떤 면에든 data-theme="dark" 를 붙이면 그 안은 다크 토큰을 받아요. 같은 마크업이 두 테마에서 어떻게 보이는지 한눈에 비교해요.

라이트
서명 완료 v3 · 9/28(월) 14:02

PCR 증폭 조건 비교

어닐링 온도를 58°C 와 62°C 로 나눠 밴드 세기를 비교했어요. 62°C 에서 비특이 밴드가 사라졌어요.

김지수 연구원 · 한빛바이오 연구소 · 첨부 파일 3개

--canvas
#FFFFFF
--ink
#171D26
--accent
#246BF8
다크
서명 완료 v3 · 9/28(월) 14:02

PCR 증폭 조건 비교

어닐링 온도를 58°C 와 62°C 로 나눠 밴드 세기를 비교했어요. 62°C 에서 비특이 밴드가 사라졌어요.

김지수 연구원 · 한빛바이오 연구소 · 첨부 파일 3개

--canvas
#0B1118
--ink
#F4F6F8
--accent
#4F8EFF
ERB
<%# 화면 전체: <html data-theme="dark">. 테마 버튼은 data-theme-toggle 하나면 돼요(저장·토스트까지 키트가 해요). %>
<button type="button" class="btn btn--secondary btn--sm" data-theme-toggle>테마 바꾸기</button>

<%# 한 면만 다크로: 그 면에 data-theme="dark" 를 붙이면 안쪽 역할 토큰이 다크 값이 돼요. %>
<div class="surface-base" data-theme="dark">
  <p class="t-heading-sm ink">PCR 증폭 조건 비교</p>
  <p class="t-body-sm ink-body">어닐링 온도를 58°C 와 62°C 로 나눠 비교했어요.</p>
</div>

그라데이션 패널

큰 라운드 패널의 은은한 면이에요. 이름 그대로의 고정 색이라 강조색과 어울리는 것만 골라요. 다크 값은 따로 있어요.

  • 하늘 첫 화면·주인공 면(강조색과 같은 계열)
  • 해 알림·공지처럼 따뜻한 안내
  • 민트 완료·성과를 보여 줄 때
  • 라일락 보조 기능 소개

빛무리 색

첫 화면 배경에 얹는 빛(aurora·halo)의 색이에요. 면을 칠하지 않고 빛을 얹어요. 다크에서는 컴포넌트가 불투명도를 낮춰요.

  • 강조 빛
  • 보라 빛
  • 민트 빛
  • 강조 후광
  • 보라 후광

대비 기준

글자는 4.5:1, 큰 글자·아이콘·테두리·상태 점은 3:1 을 넘겨야 해요. 아래는 토큰 생성기(emit.py)를 다시 돌려 나온 값이에요.

4.5:1 본문·보조 글자 t-body-* · t-caption
3:1 큰 글자·아이콘·테두리 24px 이상, 굵은 19px 이상
면제 비활성 --ink-disabled
조합 · 기준라이트다크
제목 4.5:1--ink 15.49 -
본문 4.5:1--ink-body 8.60 12.35
보조 설명 4.5:1--ink-muted 6.00 6.66
라벨 4.5:1--ink-subtle 4.64 4.54
링크·강조 글자 4.5:1--accent-text · 다크 --accent 5.06 5.43
주버튼 흰 글자 4.5:1--accent 위 4.63 -
완료 글자 4.5:1--good-text 5.08 10.13
주의 글자 4.5:1--warn-text / --warn-soft 7.72 -
오류 글자 4.5:1--bad-text 5.02 7.96
위험 버튼 글자 4.5:1--danger-ink / --danger 4.76 4.76
토스트 글자 4.5:1--toast-ink / --toast 5.70 5.26
토스트 동작 4.5:1--toast-action / --toast 5.50 5.50
오류 테두리 3:1--bad 3.61 5.25
완료 점 3:1--good 3.05 6.06
주의 점 3:1--warn - 5.52
글자 로고 4.5:1--brand-word-1·2 8.98 12.06

2026-09-28 에 vendor/design-kit/tools/tokens 에서 python3 emit.py 로 다시 돌려 전부 통과했어요. 라이트는 흰 면·neutral-50·neutral-100 가운데, 다크는 #0B1118·#12181F·#151C24 가운데 가장 불리한 면에서 잰 값이에요. 흰 바탕만 재면 링크는 5.54:1 이에요.

색만으로 말하지 않기

색을 구별하기 어려운 사람, 햇빛 아래 화면이 있어요. 상태는 아이콘과 글자로 함께 알려요.

색만 쓰면

  • PCR 증폭 조건 비교
  • 세포 배양 3일차 관찰
  • 시약 입고 기록

아이콘과 글자를 함께

  • PCR 증폭 조건 비교서명 완료
  • 세포 배양 3일차 관찰검토 대기
  • 시약 입고 기록반려됨
ERB
<%# 상태는 색 + 아이콘 + 글자. 점(dot)만으로 알리지 않아요. %>
<span class="badge badge--green"><%= icon("check", size: "sm") %>서명 완료</span>
<span class="badge badge--amber"><%= icon("clock", size: "sm") %>검토 대기</span>
<span class="badge badge--red"><%= icon("error", size: "sm") %>반려됨</span>

<%# 헬퍼로 쓸 때(글자만 들어가요. 아이콘이 필요하면 위처럼 직접) %>
<%= ui_badge "서명 완료", "green" %>

03글자

글자는 클래스 하나로 정해요

t-* 클래스 하나가 크기·행간·자간·굵기를 함께 옮겨요. 한국어는 어절 단위로 줄을 바꾸고, 본문은 한 줄 65자 안쪽에 둬요.

글자 단계

열여섯 단계예요. 20px 부터는 자간을 크기의 -2% 로 조이고, 커질수록 행간을 좁혀요. 큰 제목은 1023px·640px 아래에서 한 단계씩 줄어요. 이름을 누르면 복사돼요.

  • 88/104 · 700 · -1.76px 첫 화면 한 줄 제목 · 지금 88/104

    연구 기록을 한곳에

  • 72/85 · 700 · -1.44px 소개 화면 큰 제목 · 지금 72/85

    연구 기록을 한곳에

  • 56/66 · 700 · -1.12px 첫 화면 제목 · 지금 56/66

    과제별 노트를 모아요

  • 48/60 · 700 · -0.96px 섹션 큰 제목 · 지금 48/60

    과제별 노트를 모아요

  • 40/50 · 700 · -0.8px 섹션 제목 · 지금 40/50

    서명까지 한 화면에서

  • 34/44 · 700 · -0.68px 화면 제목(앱) · 지금 34/44

    PCR 증폭 조건 비교

  • 28/38 · 700 · -0.56px 화면 제목·창 제목 · 지금 28/38

    PCR 증폭 조건 비교

  • 24/33 · 700 · -0.48px 시트·카드 묶음 제목 · 지금 24/33

    세포 배양 3일차 관찰 기록

  • 20/29 · 600 · -0.4px 카드 제목·목록 머리 · 지금 20/29

    세포 배양 3일차 관찰 기록

  • 18/29 · 400 · 자간 0 첫 화면 설명·리드 · 지금 18/29

    어닐링 온도를 58°C 와 62°C 로 나눠 비교했어요. 62°C 에서 비특이 밴드가 사라졌어요.

  • 16/26 · 400 · 자간 0 기본 본문·입력 글자 · 지금 16/26

    어닐링 온도를 58°C 와 62°C 로 나눠 비교했어요. 62°C 에서 비특이 밴드가 사라졌어요.

  • 15/24 · 400 · 자간 0 설명·목록 행 · 지금 15/24

    어닐링 온도를 58°C 와 62°C 로 나눠 비교했어요. 62°C 에서 비특이 밴드가 사라졌어요.

  • 14/22 · 400 · 자간 0 촘촘한 설명·표 · 지금 14/22

    배지를 교체하고 세포 밀도를 측정했어요.

  • 13/21 · 400 · 자간 0 메타·도움말 · 지금 13/21

    김지수 연구원 · 9/28(월) 14:02 · 첨부 3개

  • 12/19 · 400 · 자간 0 배지·라벨 · 지금 12/19

    서명 요청 2건 · 검토 대기

  • 11/18 · 400 · 자간 0 아주 작은 표시 · 지금 11/18

    마지막 수정 3분 전

ERB
<%# 클래스 하나가 크기·행간·자간·굵기를 함께 옮겨요. 색은 ink·ink-body·ink-muted 로 따로 줘요. %>
<h2 class="t-display-sm ink">서명까지 한 화면에서</h2>
<p class="t-body-md ink-muted measure">검토를 기다리는 노트가 3건 있어요.</p>

/* CSS 에서 크기가 꼭 필요하면 토큰으로(px 를 직접 쓰지 않아요) */
.note-meta{font-size:var(--t-caption);line-height:var(--t-caption-lh)}

제목·본문·보조문

한 덩어리 안에서는 세 단계만 써요. 제목은 ink, 본문은 ink-body, 보조는 ink-muted·ink-subtle 로 색을 한 단계씩 낮춰요.

신약 후보 물질 탐색 과제

이번 주 실험 노트 12건

검토를 기다리는 노트가 3건 있어요. 금요일까지 서명해 주세요.

t-caption · t-heading-lg · t-body-md ink-muted


세포 배양 3일차 관찰

배지를 교체하고 세포 밀도를 측정했어요. 오염 징후는 없었어요.

박도윤 책임연구원 · 3시간 전

t-heading-sm · t-body-sm ink-body · t-caption ink-subtle

ERB
<p class="t-caption w-600 ink-accent">신약 후보 물질 탐색 과제</p>
<h1 class="t-heading-lg ink">이번 주 실험 노트 12건</h1>
<p class="t-body-md ink-muted">검토를 기다리는 노트가 3건 있어요. 금요일까지 서명해 주세요.</p>

<p class="t-heading-sm ink">세포 배양 3일차 관찰</p>
<p class="t-body-sm ink-body">배지를 교체하고 세포 밀도를 측정했어요.</p>
<p class="t-caption ink-subtle">박도윤 책임연구원 · <%= ago(note.updated_at) %></p>

굵기

네 가지만 써요. 가변 글꼴이라 파일 하나에 들어 있고, 없는 굵기를 브라우저가 가짜로 굵히지 않아요.

  • 서명 완료 3건 w-400본문·설명
  • 서명 완료 3건 w-500링크·값(표의 dd)
  • 서명 완료 3건 w-600버튼·라벨·작은 제목
  • 서명 완료 3건 w-700제목

숫자

표·금액·시간처럼 줄을 맞춰 읽는 숫자는 폭을 고정해요(.tnum, 표 칸은 .num). 기본 숫자는 1 이 좁아서 자리가 어긋나요.

기본 1,111.18,808.44,170.0
.tnum 1,111.18,808.44,170.0
시약사용남은 양
에탄올 99.5%1,250 mL3,750 mL
트리스 완충액480 mL11,520 mL
아가로스12.5 g487.5 g
ERB
<%# 줄을 맞춰 읽는 숫자: 글자 폭 고정 %>
<span class="t-heading-sm ink tnum"><%= number_with_delimiter(1250) %> mL</span>

<%# 표: 숫자 칸은 .num(오른쪽 정렬 + 폭 고정 + 줄바꿈 없음) %>
<table class="table">
  <thead><tr><th>시약</th><th class="num">사용</th></tr></thead>
  <tbody><tr><td>에탄올 99.5%</td><td class="num">1,250 mL</td></tr></tbody>
</table>

고정폭 글자

샘플 번호·서명 해시·명령어처럼 한 글자씩 읽어야 하는 값은 font-mono 로 써요. 문장 속 짧은 값은 code-inline, 여러 줄은 코드 상자예요.

샘플 번호 RR-0928-014 를 라벨에 적고, 냉동고 B-2 칸에 넣었어요.

전자서명
서명 해시  3f9a 1c0e 7b42 d8a5
서명 시각  2026-09-28 14:02:17
노트 버전  v3 (직전 v2 와 4줄 다름)

--font-mono 는 JetBrains Mono 를 먼저 찾지만 키트에 파일이 없어 대부분 시스템 고정폭 글꼴(SF Mono·Menlo 등)로 나와요.

한국어 줄바꿈

제목·문단·버튼은 어절 단위로 줄을 바꿔요(keep-all). 낱글자에서 끊기면 '보냈/어요'처럼 읽다가 멈추게 돼요.

글자 단위(normal)

시약 보관 온도가 기준보다 높아서 검토자에게 알림을 보냈어요.

어절 단위(keep-all)

시약 보관 온도가 기준보다 높아서 검토자에게 알림을 보냈어요.

제목 줄 고르기

제목(h1~h6)은 text-wrap: balance 로 줄 길이를 고르게 해요. 마지막 줄에 한 어절만 남지 않아요.

그냥 채우기

금요일까지 검토 의견을 남겨 주세요

balance

금요일까지 검토 의견을 남겨 주세요

한 줄 길이

본문은 .measure 로 한 줄을 34em(한글 약 40자, 영문 65자 안팎)에 묶어요. 줄이 길면 다음 줄 첫머리를 찾느라 눈이 헤매요. 좁은 화면에서는 둘이 같아요.

묶지 않음

세포를 6웰 플레이트에 웰당 2×10⁵개씩 분주하고 37°C, 5% CO₂ 배양기에서 24시간 배양했어요. 다음 날 배지를 무혈청 배지로 바꾼 뒤 후보 물질을 농도별로 처리하고, 48시간 뒤 세포 생존율을 측정했어요.

.measure

세포를 6웰 플레이트에 웰당 2×10⁵개씩 분주하고 37°C, 5% CO₂ 배양기에서 24시간 배양했어요. 다음 날 배지를 무혈청 배지로 바꾼 뒤 후보 물질을 농도별로 처리하고, 48시간 뒤 세포 생존율을 측정했어요.

링크

링크 글자만 읽어도 어디로 가는지 알게 써요. 글자 링크는 올렸을 때 밑줄, 글 본문 안에서는 늘 밑줄이에요.

서명 전에 전자서명·검토 안내를 읽어 주세요.

감사 기록 자세히 보기

글꼴 원본은 Pretendard 저장소에 있어요.

ERB
<%# 글자 링크: 올렸을 때 밑줄 %>
<a class="link" href="/product/sign">전자서명·검토 안내</a>

<%# 다음 화면으로 가는 링크: 화살표 + 44px 높이 %>
<a class="link-arrow" href="/product/security">감사 기록 자세히 보기<%= icon("arrow-right", size: "sm") %></a>

<%# 새 창: 아이콘을 붙이고 이름에 '새 창' 을 적어요 %>
<a class="link" href="https://github.com/orioncactus/pretendard" target="_blank" rel="noopener"
   aria-label="Pretendard 저장소(새 창)">Pretendard 저장소<%= icon("external", size: "sm") %></a>

목록

점 목록은 글 본문(.prose ul), 해야 할 일·포함 항목은 체크 목록(.checks), 이름과 값은 정의 목록(.kv)으로 써요.

점 목록 · .prose ul
  • 주형 DNA 50ng
  • 프라이머 F·R 각 10pmol
체크 목록 · .checks
  • 작성자 서명
  • 검토자 서명과 의견
정의 목록 · .kv
과제
신약 후보 물질 탐색
책임자
박도윤 책임연구원

글 본문

실험 노트·공지·도움말처럼 긴 글은 .prose 하나로 감싸요. 문단 간격·목록·인용·그림 설명·링크 모양이 한 번에 맞춰져요.

실험 노트 · v3 · 김지수 연구원 · 9/28(월) 14:02 · 예시

PCR 증폭 조건 비교

비특이 밴드가 계속 나와서 어닐링 온도를 두 가지로 나눠 비교했어요. 나머지 조건은 지난 노트(v2)와 같아요.

재료
  • 주형 DNA 50ng, 프라이머 F·R 각 10pmol
  • 2× PCR 마스터 믹스 12.5µL, 멸균 증류수로 25µL 까지
어닐링목표 밴드비특이 밴드
58°C1.00두 줄 보임
62°C0.94보이지 않음
표 1. 온도별 밴드 세기(58°C 를 1 로 둔 비율, 예시 값)

62°C 조건으로 다음 실험을 이어 가요. 목표 밴드 세기 차이는 오차 범위 안이에요.

박도윤 책임연구원 · 검토 의견

원본 겔 사진과 측정값은 첨부 파일 3개에 있어요. 샘플 번호는 RR-0928-014 예요.

ERB
<%# 긴 글은 .prose 하나로. 문단 간격·목록·인용·그림 설명·링크 밑줄이 함께 맞춰져요. %>
<article class="prose">
  <h2>PCR 증폭 조건 비교</h2>
  <p>비특이 밴드가 계속 나와서 어닐링 온도를 두 가지로 나눠 비교했어요.</p>
  <ul>
    <li>주형 DNA 50ng, 프라이머 F·R 각 10pmol</li>
  </ul>
  <figure>…<figcaption>표 1. 온도별 밴드 세기</figcaption></figure>
  <blockquote><p>62°C 조건으로 다음 실험을 이어 가요.</p></blockquote>
  <p>원본 데이터는 <a href="#attachments">첨부 파일 3개</a>에 있어요.</p>
</article>

글꼴

Pretendard 가변 글꼴을 한글 음절 단위 92조각으로 나눠 직접 둬요. 화면에 나온 글자가 든 조각만 받아서 첫 화면이 가벼워요.

글꼴
Pretendard Variable 1.3.9
굵기
가변 45~920 (쓰는 굵기 400·500·600·700)
나눠 받기
한글 음절 단위 92조각, 조각마다 8~44KB
이 화면
재고 있어요
두는 곳
app/assets/fonts/ds/subset/ · 외부 CDN 없음
대체 순서
-apple-system · system-ui · Segoe UI · Apple SD Gothic Neo
라이선스
SIL OFL 1.1 전문 보기 · 상업용으로 써도 돼요

04간격·면

여백과 면은 정해진 값으로만

간격·반경·그림자·면·치수·폭·겹침 순서를 모두 토큰으로 정해 두었어요. 값을 고르면 다크 모드와 좁은 화면이 함께 따라와요.

간격 눈금

4px 를 바탕으로 22단계만 써요. 누르면 토큰 이름을 복사해요.

붙은 조각

아이콘과 글자 사이, 배지 안쪽처럼 한 덩어리로 읽혀야 하는 사이

  • 0px
  • 2px
  • 4px
  • 6px
  • 8px

요소 안

컨트롤 안쪽 여백, 목록 행 안, 제목과 설명 사이

  • 10px
  • 12px
  • 16px
  • 20px

묶음 사이

카드 안쪽 여백, 입력칸과 입력칸 사이, 예시 무대의 여백

  • 24px
  • 28px
  • 32px
  • 40px

구역 사이

예시와 예시 사이, 섹션 머리와 본문 사이

  • 48px
  • 56px
  • 64px
  • 80px

섹션·첫 화면

섹션 위아래 여백과 첫 화면의 큰 여백

  • 96px
  • 120px
  • 160px
  • 200px
  • 240px

섹션 위아래 여백은 --sec-sm~--sec-hero 다섯 단계(64·96·128·160·200)를 따로 써요. 768px 보다 좁으면 64~80px 로 줄어요.

CSS
/* 간격은 --s-* 토큰만 써요. 숫자를 직접 쓰면 lint 가 잡아요 */
.note-card{padding:var(--s-24)}
.note-card__meta{margin-top:var(--s-4)}
.note-card__body{margin-top:var(--s-16)}
.note-list{display:grid;gap:var(--s-12)}

/* 섹션 위아래 여백은 따로 */
.section{padding-block:var(--sec-md)}

여백으로 관계 보여 주기

가까운 것은 좁게, 다른 묶음은 넓게 띄워요. 선이나 상자를 더하지 않아도 무엇이 한 덩어리인지 읽혀요.

검토 중 예시

PCR 증폭 조건 최적화 3차

김지수 연구원 · 9/24(목) 14:20 · 버전 3

어닐링 온도를 58℃ 에서 60℃ 로 올리니 비특이 밴드가 사라졌어요.

CSS
/* 제목과 덧말은 붙이고(4), 본문은 한 칸(16), 동작은 한 묶음 더(24) 띄워요 */
.note-card{display:flex;flex-direction:column;padding:var(--s-24);border-radius:var(--r-xl);background:var(--surface)}
.note-card__badge + .note-card__title{margin-top:var(--s-12)}
.note-card__title + .note-card__meta{margin-top:var(--s-4)}
.note-card__meta + .note-card__body{margin-top:var(--s-16)}
.note-card__foot{margin-top:var(--s-24);display:flex;gap:var(--s-8)}

안쪽 반경 = 바깥 반경 - 여백

겹친 모서리는 바깥 반경에서 여백만큼 뺀 값이어야 곡선이 나란히 흘러요. 분절(segment)의 고른 칸도 이렇게 맞췄어요.

맞음 24 - 12 = 12px
어긋남 안쪽도 24px

바깥은 . 안쪽을 같은 값으로 두면 모서리 틈이 두꺼워 보여요.

CSS
/* 안쪽 반경 = 바깥 반경 - 여백 */
.attach-card{padding:var(--s-8);border-radius:var(--r-2xl)}
.attach-card__thumb{border-radius:calc(var(--r-2xl) - var(--s-8))}

/* 키트의 분절도 같은 규칙이에요(ds/components.css) */
.segment{padding:3px;border-radius:var(--r-md)}
.segment__item{border-radius:calc(var(--r-md) - 3px)}

반경

크기가 큰 면일수록 반경도 커져요. 버튼·입력 반경은 아래 컨트롤 치수가 따로 정해요.

  • 6px · 포커스 링
  • 8px · 배지·메뉴 항목
  • 12px · 작은 버튼·아이콘 칸
  • 16px · 작은 카드·토스트
  • 20px · 카드
  • 24px · 큰 카드·창
  • 32px · 패널·시트
  • 40px · 큰 패널
  • 999px · 알약
CSS
.badge-like{border-radius:var(--r-sm)}     /* 8  배지·메뉴 항목 */
.card-like{border-radius:var(--r-xl)}      /* 20 카드 */
.sheet-like{border-radius:var(--r-3xl)}    /* 32 패널·시트 */
.pill-like{border-radius:var(--r-full)}    /* 알약 */

/* 버튼·입력 반경은 크기 토큰이 정해요 */
.btn--md{--r:var(--c-md-r)}

그림자

파란기를 섞은 여섯 단계예요. 떠 있는 높이가 높을수록 진하고 넓어요. 테두리와 함께 주지 않아요.

  • 분절의 고른 칸
  • 떠 있는 카드
  • 누를 수 있는 카드에 올렸을 때
  • 떠 있는 조각·툴팁
  • 시트·창·토스트·서랍
  • 메뉴·선택기 목록

어두운 테마에서는 그림자가 잘 보이지 않아요. 떠 있는 면은 바탕보다 한 단계 밝은 면 색으로 구분돼요.

ERB
<%# 카드는 테두리(card--line)나 그림자(card--raise·card--float) 중 하나만 줘요 %>
<div class="card card--raise">떠 있는 카드</div>
<div class="card card--float">더 높이 뜬 조각</div>
<div class="card card--line">테두리만 있는 카드</div>

<%# 화면 전용 CSS 에서 직접 줄 때도 토큰으로: box-shadow: var(--sh-pop) %>

면

구역은 선이 아니라 면의 밝기로 나눠요. 섹션 태그에 클래스 하나만 붙이면 바탕과 글자색이 함께 정해져요.

  • surface-base --canvas 기본 바탕. 대부분의 섹션
  • surface-alt --canvas-alt 한 단계 들어간 면. 섹션을 번갈아 나눌 때
  • surface-sunken --canvas-sunken 더 들어간 면. 입력 호버·우물처럼 파인 곳
  • surface-accent --accent-soft 연한 강조. 안내·추천 구역
  • surface-invert --surface-invert 반전. 강조하고 싶은 한 구역만
surface-alt 보조 글자는 --ink-muted
surface-alt on-alt 보조 글자는 --ink-muted-alt

on-alt 를 함께 붙이면 안의 ink-muted 글자가 한 단계 진해져 틴트 면 위에서도 4.5:1 을 지켜요. 밝은 테마에서는 같은 값이고 어두운 테마에서 달라져요.

ERB
<%# 섹션은 면을 번갈아 바꿔 나눠요. 틴트 면에는 on-alt 를 함께 %>
<section class="section surface-base">…</section>
<section class="section surface-alt on-alt">
  <div class="container container--wide">
    <p class="t-body-md ink-muted">보조 글자가 한 단계 진해져요</p>
  </div>
</section>
<section class="section surface-invert">…</section>

컨트롤 치수

크기 하나를 고르면 높이·반경·글자·좌우 여백이 함께 따라와요. 기본은 md(44px)예요.

  • xs 높이 28 · 반경 8
    글자 13 · 좌우 여백 10
    표 안 작은 동작
  • sm 높이 36 · 반경 10
    글자 14 · 좌우 여백 14
    목록 행·도구 막대
  • md 기본 높이 44 · 반경 12
    글자 15 · 좌우 여백 18
    대부분의 버튼·입력
  • lg 높이 52 · 반경 14
    글자 16 · 좌우 여백 22
    화면의 핵심 동작
  • xl 높이 60 · 반경 16
    글자 17 · 좌우 여백 28
    첫 화면·결과 화면의 큰 버튼
ERB
<%= ui_button "기록하기", size: "xs" %>
<%= ui_button "기록하기", size: "sm" %>
<%= ui_button "기록하기" %>                    <%# 기본 md(44px) %>
<%= ui_button "기록하기", size: "lg", variant: "primary" %>
<%= ui_button "기록하기", size: "xl" %>

같은 크기끼리 나란히

입력칸과 버튼을 같은 크기로 고르면 높이와 모서리가 정확히 맞아요.

sm · 36px
md · 44px
lg · 52px

입력칸은 sm·md·lg 세 크기예요. 글자는 크기와 상관없이 16px 라 휴대폰에서 눌러도 화면이 커지지 않아요.

ERB
<div class="sg-row">
  <input class="input input--sm" type="search" placeholder="예: 트리스 완충액" aria-label="시약 찾기">
  <%= ui_button "찾기", size: "sm" %>
</div>
<div class="sg-row">
  <input class="input input--lg" type="search" placeholder="예: 단백질 분리" aria-label="과제 찾기">
  <%= ui_button "찾기", size: "lg" %>
</div>

컨테이너 폭

내용은 컨테이너 안에 두고, 좌우 여백은 컨테이너만 줘요. 표시선은 지금 화면 폭이에요.

지금 화면 - · 좌우 여백을 뺀 내용 폭 -

  • container--sm 640px
    좁은 폼·로그인·결과 화면
  • --w-md 768px
    글 본문 폭(가운데 FAQ 등). 컨테이너 클래스는 없어요
  • container--narrow 880px
    한 가지에 집중하는 화면·앱 본문
  • container--base 1140px
    기본 콘텐츠
  • container--wide 1280px
    넓은 레이아웃(이 화면)
  • container--max 1440px
    가장 넓은 폭

표시선은 지금 내용 폭이에요. 막대가 표시선보다 길면 그 컨테이너는 지금 화면에 맞춰 줄어요. 클래스의 최대 폭은 토큰에 좌우 여백 80px 를 더한 값이에요.

ERB
<%# 좌우 여백은 컨테이너만 줘요(20 → 768px 32 → 1280px 40). 안쪽 요소는 여백을 몰라요 %>
<section class="section surface-base">
  <div class="container container--narrow">…</div>
</section>

<%# sm · narrow · base · wide · max · full %>
<div class="container container--sm">로그인·결과</div>

브레이크포인트

좁은 화면에서 맞춘 뒤 min-width 로 넓혀요. 같은 컴포넌트가 폭에 따라 모양을 바꿔요.

지금 화면 - · 창 크기를 바꾸면 따라 바뀌어요

폭이 폭부터
0px 기본 한 열로 쌓여요. 메뉴·선택기·창은 아래에서 올라오는 시트 모양이에요. 좌우 여백 20px
640px sm 격자가 2열이 되고, 메뉴·선택기는 버튼 옆 작은 목록, 창은 가운데에 떠요
768px md 바텀 시트가 가운데 창으로 바뀌어요. 좌우 여백 32px
1024px lg 상단 내비 메뉴가 펼쳐지고 햄버거가 사라져요. 격자 3~4열, 이 화면의 예시가 두 칸
1280px xl 좌우 여백 40px. 관리자 주요 숫자가 한 줄에 6개
1440px 왼쪽 진행 눈금 레일이 보여요(넓은 컨테이너 글자와 겹치지 않는 폭)
CSS
/* 좁은 화면이 기본. 넓어질 때만 min-width 로 더해요 */
.note-grid{display:grid;gap:var(--s-16)}
@media (min-width:768px){.note-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media (min-width:1024px){.note-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}

/* CSS 변수는 미디어 쿼리에 못 써요. 폭은 640 · 768 · 1024 · 1280 · 1440 중에서 */

겹침 순서

z-index 는 숫자 대신 토큰으로 줘요. 층 이름만 보면 무엇이 무엇을 덮는지 알 수 있어요.

토큰 · 값쓰는 곳
800 툴팁·축하 입자·본문 바로가기
700 토스트. 시트 안에서 연 메뉴·선택기는 바로 아래 690 에 떠요
600 시트·창 오버레이(.overlay)
500 토큰만 있어요. 시트는 창과 같은 오버레이 층에 떠요
400 서랍(drawer)과 그 덮개
300 메뉴·선택기 목록·메가 메뉴
200 상단 내비·앱바·목차 띠·진행 막대
100 탭바·하단 동작 바·입력 바·맨 위로
10 표에서 행을 고르면 뜨는 동작 막대(bulkbar)
0 기본 흐름
오른쪽 위로 갈수록 위에 그려져요. 덮개(scrim)는 아래 층을 어둡게 가려요.
CSS
/* z-index 는 숫자 대신 층 이름으로 */
.review-bar{position:sticky;bottom:0;z-index:var(--z-sticky)}
.note-head{position:sticky;top:0;z-index:var(--z-header)}

/* 한 층 안에서 순서만 필요하면 -1 · 0 · 1 로 */
.thumb__badge{position:relative;z-index:1}

안전 영역과 하단 쌓임

홈 인디케이터 위로 탭바, 그 위로 입력 바, 그 위로 토스트가 쌓여요. 없는 막대는 변수를 0 으로 덮으면 나머지가 알아서 내려와요.

변수값
홈 인디케이터 높이. 기기가 알려 주고, 없으면 0
상태 표시줄·노치 높이. 가로로 돌렸을 때 옆 노치는 --safe-l·--safe-r
58px. 앱 아래 탭바
64px. 탭바 위 입력 바
60px. 상단 내비·앱바. 앵커로 옮길 때 이만큼 비켜요

토스트는 쌓인 막대 위로 12px 떠요. 막대가 하나도 없으면 안전 영역 위로 24px, 시트·창이 열려 있으면 화면 위쪽에 떠요.

ERB
<%# 탭바도 입력 바도 없는 화면: 변수를 0 으로 덮으면 나머지가 내려와요(CSS 변수 지정은 인라인 허용) %>
<div class="app-page" style="--tabbar-h:0px; --dock-h:0px">…</div>

<%# 키트가 쌓는 방법(ds/app.css)
    입력 바   bottom: calc(var(--tabbar-h) + var(--safe-b))
    토스트    bottom: calc(var(--safe-b) + var(--tabbar-h) + var(--dock-h) + var(--s-12))
    시트 바닥 padding-bottom: calc(20px + var(--safe-b)) %>

05모션

움직임은 뜻이 있을 때만

곡선 여섯 가지와 시간 일곱 단계만 써요. 등장과 퇴장은 다른 곡선으로, 상태가 바뀌었을 때만 움직여요.

이징 곡선

가로는 시간, 세로는 진행이에요. 재생을 누르면 그래프 위 점과 아래 막대의 점이 그 곡선으로 움직여요.

  • 등장 .16, 1, .3, 1 빠르게 나와 길게 멈춰요. 창·메뉴·토스트처럼 나타나는 것
  • 퇴장 .55, .06, .68, .19 천천히 떠나 빠르게 사라져요. 등장보다 짧게
  • 이동·색 .4, 0, .2, 1 자리를 옮기거나 색이 바뀔 때. 기본값
  • 강조 .64, 0, .3, 1 천천히 모았다가 힘 있게 끝나요. 키트 부품은 아직 안 써요
  • 튕김 .34, 1.56, .64, 1 살짝 넘쳤다 돌아와요. 스위치 손잡이·체크
  • 시트 .32, .72, 0, 1 손으로 끌어 올린 듯 붙는 시트·서랍
CSS
/* 곡선은 --ease-* 토큰으로만. 등장 out · 퇴장 in · 이동 inout */
.note-menu[data-state="open"]{animation:pop var(--d-2) var(--ease-out) both}
.note-menu[data-state="closing"]{animation:pop-out var(--d-1) var(--ease-in) both}
.note-card{transition:transform var(--d-3) var(--ease-out)}
.sign-toggle__knob{transition:transform var(--d-3) var(--ease-spring)}
.review-sheet{animation:sheet-up var(--d-5) var(--ease-sheet) both}

지속시간

작은 변화일수록 짧게, 멀리 움직일수록 길게 줘요. 재생하면 일곱 막대가 동시에 차올라 길이를 비교할 수 있어요.

  • 120ms 호버·색 변화·눌림
  • 180ms 기본 전환·메뉴·툴팁 등장
  • 240ms 화살표 밀림·스위치 손잡이·시트 닫힘
  • 320ms 탭 밑줄·아코디언·창 등장
  • 420ms 시트·서랍
  • 640ms 스크롤 리빌
  • 900ms 큰 화면 전환
한 화면에서 200ms 넘는 전환은 동시에 하나만
CSS
/* 바뀌는 속성만 적고, 시간은 --d-1 ~ --d-7 에서 고른다(transition: all 금지) */
.attach-row{transition:background-color var(--d-1) var(--ease-inout)}
.version-tab__ink{transition:transform var(--d-4) var(--ease-out)}
.review-drawer{transition:transform var(--d-5) var(--ease-sheet)}

등장은 ease-out, 퇴장은 ease-in

같은 곡선으로 닫으면 사라지는 끝이 늘어져 굼떠 보여요. 닫아 보면 차이가 가장 잘 보여요.

맞음 등장 ease-out 320ms · 퇴장 ease-in 180ms
어긋남 등장·퇴장 모두 ease-out 320ms
CSS
/* 들어올 때는 ease-out 으로 길게, 나갈 때는 ease-in 으로 한 단계 짧게 */
.note-pop{opacity:0;transform:translateY(calc(var(--s-8) * -1)) scale(.96);
  transition:opacity var(--d-2) var(--ease-in),transform var(--d-2) var(--ease-in)}
.note-pop[data-open]{opacity:1;transform:none;
  transition:opacity var(--d-4) var(--ease-out),transform var(--d-4) var(--ease-out)}

애니메이션 이름

키트에 들어 있는 키프레임이에요. 타일을 누르면 한 번 재생해요. 나가는 짝이 있으면 함께 적었어요.

CSS
/* 키트 키프레임은 이름만 불러 쓴다(ds/components.css) */
.review-scrim{animation:fade-in var(--d-4) var(--ease-inout) both}
.sign-badge{animation:rise var(--d-3) var(--ease-out) both}
.confirm-modal{animation:pop var(--d-4) var(--ease-out) both}
.review-sheet{animation:sheet-up var(--d-5) var(--ease-sheet) both}

/* 뼈대는 클래스만: <div class="skel skel--line"></div> 가 shimmer 를 돌린다 */

움직임 원칙

움직일지 말지 고민되면 이 네 줄로 정해요.

원칙이렇게 해요
뜻이 있을 때만무엇이 바뀌었는지, 어디서 왔는지 알려야 할 때만 움직여요. 장식으로 움직이지 않아요.
200ms 넘는 건 하나만시트가 올라오는 동안 목록까지 움직이면 눈 둘 곳을 잃어요. 긴 전환은 한 번에 하나.
제품 화면은 최소로스크롤 연출은 랜딩·소개 화면에만 써요. 로그인 뒤에는 상태 전환과 화면 전환만.
나갈 때는 더 짧게등장은 ease-out, 퇴장은 ease-in 으로 한 단계 짧게 줘요.

모션 축소

기기에서 '동작 줄이기'를 켠 사람에게는 움직임 없이 끝 상태를 바로 보여 줘요. 내용이 사라지면 안 돼요.

설정 확인 중

이 기기의 모션 축소 설정을 읽고 있어요.

  • 전환과 애니메이션은 0.01ms 로 줄어 바로 끝 상태가 돼요
  • 스크롤 리빌은 처음부터 보이는 상태로 둬요
  • 뼈대 빛·흐르는 띠·배경 빛은 멈추고, 스크롤 장면은 차례로 나열해요

켜는 곳: iPhone 은 설정 > 손쉬운 사용 > 동작 > 동작 줄이기, Mac 은 시스템 설정 > 손쉬운 사용 > 디스플레이 > 동작 줄이기, Windows 는 설정 > 접근성 > 시각 효과 > 애니메이션 효과 끄기.

CSS
/* 새 애니메이션은 모션 축소에서 끝 상태로 끝나게 */
.sign-badge{animation:rise var(--d-3) var(--ease-out) both}
@media (prefers-reduced-motion:reduce){
  .sign-badge{animation:none}   /* 보이는 상태 그대로. 투명하게 남기지 않는다 */
}

/* 스크립트에서 확인할 때 */
/* const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches */

06아이콘

아이콘은 한 뜻에 하나

24px 격자에 선 1.7px 로 그린 53종이에요. 색은 글자색을 따라가고, 크기는 네 단계만 써요.

전체 53종

이름이나 뜻으로 찾아요. 누르면 icon("이름") 코드를 복사해요.

53개

ERB
<%# 장식 아이콘: 기본으로 aria-hidden 이 붙어요 %>
<%= icon("search") %>

<%# 아이콘만으로 뜻을 알릴 때는 label: 로 이름을 줘요(role="img") %>
<%= icon("lock", label: "서명 완료") %>

<%# 스크립트에서 그릴 때는 머리말의 <meta name="ds-icons"> 주소를 읽어요 %>

크기 네 단계

글자 크기에 맞춰 고르고, 그 사이 크기는 만들지 않아요.

  • ico--sm 16px · 작은 버튼·배지·표 안·보조 줄
  • ico 20px · 기본. 버튼·목록·입력·알림
  • ico--lg 24px · 탭바·아이콘 칸·강조
  • ico--xl 32px · 빈 상태 그림·결과 화면
ERB
<%= icon("doc", size: "sm") %>   <%# 16 %>
<%= icon("doc") %>               <%# 20 기본 %>
<%= icon("doc", size: "lg") %>   <%# 24 %>
<%= icon("doc", size: "xl") %>   <%# 32 %>

아이콘 버튼

글자 없는 버튼은 aria-label 에 할 일을 적어요. 마우스를 올리거나 Tab 으로 가면 이름이 떠요.

md · 44px
sm · 36px, 누르는 범위는 44px
겔 전기영동 사진.png

'X 버튼'처럼 모양을 적지 말고 '닫기'처럼 누르면 일어날 일을 적어요.

ERB
<%# square: true 면 글자 대신 아이콘만 그리고, 첫 인자가 aria-label 이 돼요 %>
<%= ui_button "노트 고치기", variant: "ghost", square: true, icon_name: "edit", data: { tip: "노트 고치기" } %>
<%= ui_button "첨부 지우기", variant: "ghost", size: "sm", square: true, icon_name: "trash", data: { tip: "첨부 지우기" } %>

뜻 나눠 쓰기

비슷해 보여도 쓰는 곳이 달라요. 한 뜻에 한 아이콘만 써야 화면마다 헷갈리지 않아요.

아이콘쓰는 곳
voice음성 대화 화면으로 들어가는 곳
mic마이크를 켜는 동작. 누르는 동안 녹음
file-audio저장해 둔 녹음 파일을 올리는 곳
play · pause녹음 듣기의 재생과 일시정지
info · warnerror · success알림 배너·결과의 상태(안내·주의·오류·완료). 색과 함께 꼭 붙여요

글자와 나란히

한 줄이면 가운데에 맞추고, 여러 줄이면 첫 줄 가운데에 맞춰요. 색은 옆 글자색을 그대로 따라가요.

한 줄 · 가운데
9/24(목) 14:20 첨부 3개 검토자 2명
여러 줄 · 첫 줄에 맞춤

서명한 노트는 고칠 수 없어요. 고칠 게 있으면 새 버전으로 기록해 주세요.

첫 줄에 맞춤

서명한 노트는 고칠 수 없어요. 고칠 게 있으면 새 버전으로 기록해 주세요.

가운데로 떠 있음
버튼 안 · 뜻이 있는 아이콘
PCR 증폭 3차
ERB
<%# 한 줄: 가운데 맞춤 %>
<span class="note-meta"><%= icon("clock", size: "sm") %>9/24(목) 14:20</span>
<%# .note-meta{display:inline-flex;align-items:center;gap:var(--s-6)} %>

<%# 여러 줄: 첫 줄 가운데에. 여백 = (줄 높이 24 - 아이콘 16) / 2 %>
<p class="note-lock"><%= icon("lock", size: "sm") %><span>서명한 노트는 고칠 수 없어요.</span></p>
<%# .note-lock{display:flex;align-items:flex-start;gap:var(--s-8)} .note-lock .ico{margin-top:var(--s-4)} %>

<%# 버튼 안: 앞은 icon_name, 뒤는 icon_after %>
<%= ui_button "PDF 내려받기", icon_name: "download", size: "sm" %>
<%= ui_button "버전 기록", icon_name: "arrow-right", icon_after: true, size: "sm", variant: "ghost" %>

07버튼

누를 곳은 하나로 분명하게

주버튼은 한 화면에 하나예요. 크기를 고르면 높이·반경·글자·여백이 한 번에 따라와요.

변형 7가지

누르면 일어날 일의 무게대로 고르세요. 여기서는 비교하려고 주버튼을 나란히 두었지만 실제 화면에는 하나만 둬요.

주버튼btn--primary

화면의 주 동작 하나. 되돌릴 수 있는 다음 단계예요.

보조btn--secondary

기본값이에요. 반투명 틴트라 어떤 면에도 얹혀요.

연한 강조btn--soft

주버튼 곁에서 두 번째로 중요한 동작이에요.

테두리btn--outline

목록·표 안처럼 면이 이미 많은 곳에 둬요.

고스트btn--ghost

툴바·아이콘 버튼·취소처럼 가장 가벼운 동작이에요.

반전btn--invert

글자색으로 칠한 면. 파랑 주버튼이 따로 있을 때 두 번째 강조예요.

위험btn--danger

되돌릴 수 없는 동작. 확인 창 안에서만 주버튼이 돼요.

마우스를 올리고, 누르고, Tab 으로 옮겨 보세요. 위험 버튼은 누르면 확인 창이 떠요.

ERB
<%# variant: primary(한 화면에 하나) · secondary(기본값) · soft · outline · ghost · invert · danger %>
<%= ui_button "저장하기", variant: "primary" %>
<%= ui_button "미리 보기" %>
<%= ui_button "검토 요청하기", variant: "soft" %>
<%= ui_button "내보내기", variant: "outline" %>
<%= ui_button "취소", variant: "ghost" %>
<%= ui_button "시작하기", variant: "invert" %>
<%= ui_button "노트 지우기", variant: "danger",
      confirm: "첨부 파일 3개가 함께 지워져요.", confirm_title: "이 노트를 지울까요?" %>

<%# 헬퍼 없이 쓸 때(키트 마크업) %>
<button class="btn btn--primary" type="button">
  <span class="btn__label">저장하기</span>
</button>

반전 무대 위

어두운 판 위에서는 주버튼을 그대로 쓰고, 보조 버튼은 키트가 판에 맞춰 반투명 흰 면으로 바꿔요.

과제 기록을 오늘부터 한곳에

반전 버튼(invert)은 판과 같은 색이 돼서 여기에는 쓰지 않아요.

ERB
<%# 반전 무대(.surface-invert) 위 %>
<%# 주버튼은 그대로, 보조는 키트가 반투명 흰 면으로 바꾼다 %>
<section class="section surface-invert">
  <%= ui_button "무료로 시작하기", variant: "primary", size: "lg", href: "/signup" %>
  <%= ui_button "도입 문의하기", size: "lg", href: "/customers" %>
</section>

<%# 반전 버튼(invert): 글자색 면이라 바탕 위에서 파랑 주버튼과 따로 보여야 할 때 %>
<%# 예: 첫 화면 내비의 시작 버튼 %>
<%= ui_button "무료로 시작하기", variant: "invert", size: "sm", pill: true, href: "/signup" %>

반전 버튼 쓰는 곳

첫 화면처럼 파랑 주버튼이 이미 있을 때, 내비의 시작 버튼을 반전 버튼으로 두면 둘이 다투지 않아요.

실험 노트, 검토부터 서명까지

노트 쓰기
ERB
<%# 반전 무대(.surface-invert) 위 %>
<%# 주버튼은 그대로, 보조는 키트가 반투명 흰 면으로 바꾼다 %>
<section class="section surface-invert">
  <%= ui_button "무료로 시작하기", variant: "primary", size: "lg", href: "/signup" %>
  <%= ui_button "도입 문의하기", size: "lg", href: "/customers" %>
</section>

<%# 반전 버튼(invert): 글자색 면이라 바탕 위에서 파랑 주버튼과 따로 보여야 할 때 %>
<%# 예: 첫 화면 내비의 시작 버튼 %>
<%= ui_button "무료로 시작하기", variant: "invert", size: "sm", pill: true, href: "/signup" %>

크기 5단계

높이만 따로 바꾸지 않아요. 크기 이름 하나로 반경·글자·좌우 여백이 함께 바뀌어요.

xs · 28px 표 안·배지 옆
sm · 36px 목록 행·툴바
md · 44px 기본 기본·폼 끝
lg · 52px 모바일 하단·시트
xl · 60px 첫 화면 한 곳

xs·sm 은 보이는 크기가 작아도 누르는 범위는 44px 이에요. 붙어 있는 버튼끼리는 간격을 8px 넘게 두세요.

ERB
<%# size: xs 28 · sm 36 · md 44(기본) · lg 52 · xl 60. 높이·반경·글자·좌우 여백이 한 세트로 바뀐다 %>
<%= ui_button "기록하기", variant: "primary", size: "xs" %>
<%= ui_button "기록하기", variant: "primary", size: "sm" %>
<%= ui_button "기록하기", variant: "primary" %>
<%= ui_button "기록하기", variant: "primary", size: "lg" %>
<%= ui_button "기록하기", variant: "primary", size: "xl" %>
<%# xs·sm 은 보이는 크기는 작아도 누르는 범위는 44px(::before) %>

앞·뒤 아이콘

앞 아이콘은 무엇을 하는지, 뒤 아이콘은 어디로 가는지 알려요. 글자를 대신하지 않아요.

ERB
<%# 앞 아이콘(기본) · 뒤 아이콘(icon_after: true). 아이콘은 장식이라 aria-hidden 이 저절로 붙는다 %>
<%= ui_button "노트 쓰기", variant: "primary", icon_name: "plus" %>
<%= ui_button "PDF 내려받기", icon_name: "download" %>
<%= ui_button "다음 단계", variant: "soft", icon_name: "arrow-right", icon_after: true %>
<%= ui_button "전체 보기", variant: "outline", icon_name: "chevron-right", icon_after: true %>

아이콘만

정사각(btn--square)에 aria-label 로 이름을 붙이고, 툴팁으로 눈에도 이름을 보여요.

sm 36
md 44
lg 52
고스트 44
ERB
<%# square: true 면 첫 인자가 aria-label 이 된다 %>
<%# 툴팁(data-tip)으로 눈에도 이름을 보여 준다 %>
<%= ui_button "고치기", square: true, icon_name: "edit", size: "sm",
      data: { tip: "고치기" } %>
<%= ui_button "링크 공유", square: true, icon_name: "share",
      data: { tip: "링크 공유" } %>
<%= ui_button "PDF 내려받기", square: true, icon_name: "download", size: "lg",
      data: { tip: "PDF 내려받기" } %>
<%= ui_button "더 보기", square: true, icon_name: "more", variant: "ghost",
      data: { tip: "더 보기" } %>

알약

필터·초대·구독처럼 가볍게 켜고 끄는 동작에 둥근 끝(btn--pill)을 줘요.

ERB
<%= ui_button "과제 만들기", variant: "primary", pill: true, icon_name: "plus" %>
<%= ui_button "연구원 초대하기", pill: true %>
<%= ui_button "알림 받기", variant: "soft", size: "sm", pill: true, icon_name: "bell" %>

가로 전체

좁은 화면의 폼 끝·시트 아래에서 btn--block 으로 한 줄을 다 써요. 주버튼이 위예요.

휴대폰 폭 예시
ERB
<%# block: true 면 가로 전체. 좁은 화면 폼 끝·시트 아래·로그인 %>
<%= ui_button "노트 저장하기", variant: "primary", size: "lg", block: true %>
<%= ui_button "임시 저장", size: "lg", block: true %>

처리 중

누르면 1.2초 동안 스피너가 돌고 다시 눌리지 않아요. 글자는 투명해질 뿐 자리를 지켜서 버튼 폭이 그대로예요.

누르면 누르기 전과 처리 중의 폭을 여기에 적어요.

처음부터 처리 중
ERB
<%# 처음부터 처리 중으로 그릴 때 %>
<%= ui_button "보내기", variant: "primary", busy: true, "aria-busy": "true" %>

<%# 스크립트로 켜고 끌 때 %>
<%# 글자는 투명해지고 자리는 그대로라 버튼 폭이 줄지 않는다 %>
btn.setAttribute("data-busy", "")
btn.setAttribute("aria-busy", "true")
// 끝나면
btn.removeAttribute("data-busy")
btn.removeAttribute("aria-busy")

<%# Turbo 로 보내는 폼은 할 일이 없다 %>
<%# 보내는 동안 form[aria-busy] 의 제출 버튼이 같은 모양이 된다 %>

누르는 느낌

카드·타일은 .pressable 을 붙이면 누를 때 살짝 줄고 옅은 덮개가 깔려요. 스크롤하려고 댄 손가락에는 반응하지 않아요.

ERB
<%# 누르면 98%로 줄고 옅은 덮개가 깔린다 %>
<%# --deep 95%(작은 타일) · --flat 덮개만(가로로 긴 행) %>
<button class="card card--line pressable pressable--deep" type="button">
  <span class="feature__icon" aria-hidden="true"><%= icon "edit" %></span>
  <span class="t-body-sm w-600 ink">노트 쓰기</span>
</button>

<%= row_item title: "실험 노트 RN-0142", sub: "김지수 연구원 · 5분 전",
      href: "/app/notes", chev: true, class: "pressable pressable--flat" %>

<%# 버튼(.btn)은 따로 붙이지 않아도 눌리면 97%로 준다 %>

비활성: disabled 와 aria-disabled

모양은 같아도 키보드와 화면 읽기에서 다르게 움직여요. 왜 안 되는지 알려야 하면 aria-disabled 를 써요.

disabled
Tab 초점
건너뛰어요
누름
이벤트가 오지 않아요
화면 읽기
흐리게 표시됨
쓰는 곳
아직 채울 칸이 남은 폼 제출
aria-disabled="true"
PDF 내려받기
Tab 초점
머물러요
누름
이벤트가 와요. 스크립트가 막고 이유를 보여요
화면 읽기
흐리게 표시됨 + 툴팁 설명
쓰는 곳
링크 버튼(href 를 빼요) · 이유를 알려야 할 때

오른쪽 서명하기를 눌러 보거나 Tab 으로 옮겨 보세요. 왜 누를 수 없는지 툴팁으로 알려요.

ERB
<%# disabled: 초점도 누름도 없다 %>
<%# 폼 제출처럼 '아직 누를 수 없음'만 알리면 될 때 %>
<%= ui_button "서명하기", variant: "primary", disabled: true %>

<%# aria-disabled: 초점은 받는다. 왜 안 되는지 툴팁이나 안내로 알려 줄 때 %>
<%# 누름은 스크립트에서 막는다 %>
<%= ui_button "서명하기", variant: "primary", "aria-disabled": "true",
      data: { tip: "검토자를 먼저 골라 주세요", tip_toggle: "" } %>

<%# 링크 버튼에는 disabled 가 없다. href 를 빼고 aria-disabled 를 단다 %>
<a class="btn btn--secondary" aria-disabled="true">
  <span class="btn__label">PDF 내려받기</span>
</a>

상태별 모양

14_component_states 기준이에요. 상태가 바뀌어도 버튼 크기는 그대로고, 올림은 마우스가 있는 기기에서만, 초점 링은 키보드로 옮길 때만 보여요.

버튼 일곱 가지가 기본·올림·누름·초점·비활성·처리 중일 때의 모양을 나란히 그린 표예요. 눌러 볼 수 있는 버튼은 위 '변형 7가지'에 있어요.

상태모양 · 토큰
올림한 단계 진한 면. 터치 기기에는 없어요
누름97%로 줄고 더 진한 면
초점바깥 2px 링. 버튼 모서리를 따라가요
비활성모양 그대로 42%로 흐리게. 누름·올림 반응 없음
처리 중글자만 투명, 가운데 스피너. 폭 그대로data-busyaria-busy

화살표 알약

이 시스템의 대표 링크 버튼이에요. 올리면 화살표가 오른쪽으로 조금 밀려요. 첫 화면·마지막 안내에 한 번씩만 써요.

기본 52 · accent 강조색 · lg 60 · sm 44

ERB
<%# 이 시스템의 대표 버튼(링크). 올리면 화살표가 오른쪽으로 3px 밀린다 %>
<%= ui_pill "요금 보기", "/pricing" %>
<%= ui_pill "무료로 시작하기", "/signup", accent: true %>
<%= ui_pill "노트 둘러보기", "/product/notes", size: "lg" %>
<%= ui_pill "사례 보기", "/customers", size: "sm" %>

글 링크 화살표

버튼만큼 무겁지 않은 '더 알아보기'에 써요. 링크 글자만 읽어도 어디로 가는지 알게 적어요.

전자서명 알아보기 감사 기록이 남는 방식 보기

문장 안에서는 밑줄 없는 글 링크로 둬요. 기준은 자주 묻는 질문에 모아 두었어요.

ERB
<a class="link-arrow t-body-sm" href="/product/sign">
  전자서명 알아보기 <%= icon "arrow-right", size: "sm" %>
</a>
<p>기준은 <%= link_to "자주 묻는 질문", "/faq", class: "link" %>에 모아 두었어요.</p>

빛 지나감

화면에 들어올 때 빛이 한 번 지나가요. 한 화면에 하나, 주 동작에만 써요. 모션 축소 설정이면 빛 없이 그대로예요.

무료로 시작하기
ERB
<%# 화면에 들어올 때 빛이 한 번 지나간다. 한 화면에 하나, 주 동작에만 %>
<%= ui_button "무료로 시작하기", variant: "primary", size: "lg", class: "shine" %>
<%# 다시: dsShine(el) · 늦게: data-shine-delay="600" · 모션 축소면 빛 없이 그대로 %>

버튼 안 단축키

키보드로 일하는 넓은 화면에서 자주 쓰는 동작의 단축키를 글자 뒤에 보여요.

예시 단축키예요. 실제 동작은 화면마다 따로 붙여요.

ERB
<%# 단축키는 버튼 글자 뒤에. 주버튼·반전 버튼 안에서는 키 테두리가 글자색을 따른다 %>
<button class="btn btn--primary btn--sm" type="button">
  <span class="btn__label">저장하기 <kbd class="kbd">⌘S</kbd></span>
</button>
<button class="btn btn--secondary btn--sm" type="button">
  <span class="btn__label">노트 찾기 <kbd class="kbd">⌘K</kbd></span>
</button>

버튼 묶음 배치

보조는 왼쪽, 주버튼은 오른쪽 끝이에요. 좁은 화면에서 글이 길면 세로로 쌓고 주버튼을 위에 둬요. 순서는 화면 전체에서 같아요.

넓은 화면 · 창과 폼 끝

실험 노트를 저장할까요?

좁은 화면 · 두 칸 같은 폭

실험 노트를 저장할까요?

글이 길 때 · 세로로 쌓기

서명하면 내용을 고칠 수 없어요.

지우기·해지처럼 되돌릴 수 없는 동작은 주버튼 자리에 두지 않아요. 확인 창 안에서만 위험 버튼이 주버튼이 돼요.

ERB
<%# 가로: 보조가 왼쪽, 주버튼이 오른쪽 끝 %>
<div class="row">
  <%= ui_button "취소", data: { close: "" } %>
  <%= ui_button "저장하기", variant: "primary" %>
</div>

<%# 좁은 화면 시트: 아래 버튼 두 칸은 키트가 같은 폭으로 나눈다 %>
<%= ui_sheet "note-save", title: "노트를 저장할까요?" do |s| %>
  <% s.foot do %>
    <%= ui_button "취소", data: { close: "" } %>
    <%= ui_button "저장하기", variant: "primary" %>
  <% end %>
<% end %>

<%# 글이 길면 세로로 쌓고 주버튼을 위에(stack: true) %>
<% app_actionbar stack: true do %>
  <%= ui_button "서명하고 제출하기", variant: "primary", size: "lg" %>
  <%= ui_button "임시 저장", size: "lg" %>
<% end %>

버튼 문구

버튼은 누르면 일어날 일을 동사로 말해요. 360px 에서 한 줄로 끝나야 하고, 넘치면 버튼을 키우지 말고 문구를 줄여요.

  • 동사로 끝내요 이렇게 말고 서명 이렇게 서명하기
  • 할 일을 말해요(지우기 창) 이렇게 말고 확인 이렇게 지우기
  • 짧게 써요 이렇게 말고 지금 바로 노트 작성해 보기 이렇게 노트 쓰기
  • 거절은 결과로 말해요 이렇게 말고 아니요 이렇게 그대로 두기
  • 같은 동작은 같은 이름 이렇게 말고 검토 철회 이렇게 검토 취소
360px 에서 버튼 자리최대권장
가로 전체 한 개btn--lg19자10자
시트 아래 두 칸btn--md9자6자
하단 동작 바 두 칸btn--lg8자6자
시트 아래 세 칸btn--md4자피해요
표·목록 행 안btn--sm4자2~4자

헬퍼로 쓰기

화면에서는 ui_button 한 줄로 써요. 클래스 이름을 손으로 조립하지 않아요.

variant:
primary · secondary · soft · outline · ghost · invert · danger기본값 "secondary"
size:
xs 28 · sm 36 · md 44 · lg 52 · xl 60기본값 "md"
href:method:
주면 링크가 돼요. GET 이 아니면 Turbo 가 폼 제출로 바꿔요기본값 없음
icon_name:icon_after:
아이콘 스프라이트 이름과 글자 뒤에 둘지기본값 없음 · false
block:pill:square:
가로 전체 · 알약 · 정사각(첫 인자가 aria-label)기본값 false
busy:
처음부터 처리 중(data-busy)기본값 false
confirm:confirm_title:confirm_tone:
누르기 전에 확인 창. 위험 버튼이면 창도 위험 색기본값 없음
**opts
type · disabled · aria-* · data 는 그대로 태그에 붙어요
ERB
<%# app/helpers/ui_helper.rb %>
ui_button(label,
          variant: "secondary", size: "md",
          href: nil, method: nil,
          icon_name: nil, icon_after: false,
          block: false, pill: false, square: false, busy: false,
          confirm: nil, confirm_title: nil, confirm_tone: nil, **opts)

<%= ui_button "저장하기", variant: "primary", type: "submit" %>
<%= ui_button "자세히 보기", href: note_path(note),
      icon_name: "arrow-right", icon_after: true %>
<%= ui_button "노트 지우기", href: note_path(note), method: :delete, variant: "danger",
      confirm: "첨부 파일 3개가 함께 지워져요.", confirm_title: "이 노트를 지울까요?" %>
<%= ui_button "검토 열기", data: { open: "review-sheet" } %>

08표시

상태는 색과 글자로 함께 알려요

배지·점·아바타·툴팁·가격·시간처럼 읽기만 하는 조각이에요. 색만으로 구분하지 않고 늘 글자를 곁에 둬요.

배지

상태를 짧은 명사로 알려요. 점을 찍지 않고 면과 글자로 구분해요. 초록·호박·빨강·보라는 상태에만 써요.

작성 중

badge--neutral

기본 상태·분류

검토 요청

badge--accent

확인이 필요한 것

서명 완료

badge--green

끝난 것·통과

기한 임박

badge--amber

주의·기다림

반려

badge--red

막힘·실패

자동 기록

badge--violet

시스템이 한 일

새 기능

badge--solid

눈에 띄어야 할 한 곳

사본

badge--outline

가벼운 표시·읽기 전용

크기 · 모양 기본 26 크게 32 알약 검토 3건
ERB
<%# ui_badge(글자, 톤, size: "lg", pill: true) %>
<%# 톤: neutral(기본) accent green amber red violet solid outline %>
<%= ui_badge "작성 중" %>
<%= ui_badge "검토 요청", "accent" %>
<%= ui_badge "서명 완료", "green" %>
<%= ui_badge "기한 임박", "amber" %>
<%= ui_badge "반려", "red" %>
<%= ui_badge "자동 기록", "violet" %>
<%= ui_badge "새 기능", "solid" %>
<%= ui_badge "사본", "outline" %>
<%= ui_badge "검토 3건", "accent", size: "lg", pill: true %>

상태 점

연결·가동처럼 계속 바뀌는 상태에만 써요. 점만 두지 않고 반드시 글자를 곁에 둬요.

  • 사용 중 PCR 장비 A dot--live
  • 쉬는 중 원심분리기 2번 dot--idle
  • 점검 필요 초저온 냉동고 dot--warn
  • 연결 끊김 현미경 카메라 dot--down
ERB
<%# 점은 늘 글자와 함께. 점 자체는 장식(aria-hidden)이고 뜻은 옆 글자가 전한다 %>
<span class="row row--tight">
  <span class="dot dot--live" aria-hidden="true"></span><span class="t-body-sm ink">사용 중</span>
</span>
<%# dot--live(초록 고리) · dot--idle(회색) · dot--warn(호박) · dot--down(빨강) %>

태그 칩

노트에 붙은 키워드처럼 누르지 않는 표시는 중립 알약 배지로 둬요. 눌러서 거르는 칩은 탐색의 필터 칩이에요.

HEK293 세포 배양 3일차 관찰

  • 세포 배양
  • HEK293
  • 형광 현미경
  • 2026 하반기
  • 외부 공유

키트에 태그 전용 클래스는 없어요. badge--neutral · badge--outline 에 badge--pill 을 더해요.

ERB
<%# 키트에 정적 태그 전용 클래스는 없다. 키워드는 중립 알약 배지로 둔다(누르는 필터는 .chip-f) %>
<ul class="row row--tight" aria-label="노트 키워드">
  <li><%= ui_badge "세포 배양", pill: true %></li>
  <li><%= ui_badge "PCR", pill: true %></li>
  <li><%= ui_badge "외부 공유", "outline", pill: true %></li>
</ul>

아바타·아바타 묶음

누가 함께하는지 겹친 얼굴로 보여요. 사진이 없으면 이니셜 한 글자, 넘치면 +수. 묶음 전체에 사람 수를 이름으로 붙여요.

김박이+3 sm 28 · 참여 연구원 6명
김박최정+1 기본 36 · 올리면 살짝 벌어져요
한윤 lg 44 · 검토자 2명
한서준 책임연구원 한빛바이오 연구소 · xl 56
ERB
<%# 묶음 전체에 사람 수를 aria-label 로 %>
<%# 사진이 없으면 이니셜 한 글자와 면 색(avatar--accent --good --warn --note) %>
<span class="avatars avatars--sm" role="img" aria-label="참여 연구원 6명">
  <span class="avatar--accent">김</span>
  <span class="avatar--good">박</span>
  <span class="avatar--note">이</span>
  <span class="avatars__more">+3</span>
</span>
<%# 크기: avatars--sm 28 · 기본 36 · --lg 44 · --xl 56 %>

툴팁

아이콘만 있는 버튼의 이름을 눈에도 보여요. 올리면 0.4초 뒤, Tab 으로 오면 바로 떠요. 위에 자리가 없으면 아래로 떠요.

노트 번호 누르면 열리고 다시 누르면 닫혀요

ERB
<%# data-tip="문구". 올리면 400ms 뒤, Tab 초점이면 바로 %>
<%# 위에 자리가 없으면 아래로 뜬다 %>
<%= ui_button "링크 복사", square: true, icon_name: "copy",
      data: { tip: "링크 복사" } %>

<%# 손가락으로도 열어야 하는 도움말 버튼은 data-tip-toggle %>
<button class="tip-trigger" type="button" data-tip-toggle
        data-tip="과제 번호 뒤에 순서대로 붙어요"
        aria-label="노트 번호 도움말"><%= icon "info", size: "sm" %></button>

키

단축키 안내에 써요. 조합은 .kbd-combo 로 묶고, 지금 누른 키는 data-on 으로 밝혀요.

⌘K
노트 찾기
Shift?
단축키 모두 보기
JK
아래·위 노트로
Esc
창 닫기

예시 단축키예요. 파란 J 가 지금 누른 키 모양이에요.

ERB
<span class="kbd-combo"><kbd class="kbd">⌘</kbd><kbd class="kbd">K</kbd></span>
<span class="kbd-combo"><kbd class="kbd">Shift</kbd><kbd class="kbd">?</kbd></span>
<span class="kbd-combo">
  <kbd class="kbd kbd--lg" data-on>J</kbd><kbd class="kbd kbd--lg">K</kbd>
</span>
<%# kbd--lg 큰 키 · [data-on] 지금 누른 키 · 버튼 안에서는 글자색을 따른다 %>

글 안 코드

문장 속 번호·설정값·경로를 .code-inline 으로 감싸요. 긴 경로는 칸 안에서 줄을 바꿔요.

노트 번호는 RN-2026-0142 처럼 과제 번호 뒤에 붙어요.

버튼에 data-confirm 을 달면 누르기 전에 확인 창이 떠요.

이 섹션은 app/views/design_system/sections/_display.html.erb 한 파일이에요.

ERB
<p>노트 번호는 <code class="code-inline">RN-2026-0142</code> 처럼 붙어요.</p>
<%# 긴 경로도 칸 안에서 줄을 바꾼다(overflow-wrap:anywhere) %>

코드 상자

설정값·명령어·예시 코드를 그대로 가져가게 해요. 복사를 누르면 잠깐 '복사함'으로 바뀌고, 긴 줄은 상자 안에서만 옆으로 밀려요.

Ruby
# app/controllers/design_system_controller.rb
# 화면을 바꾸지 않는 응답에서 토스트만 띄운다(Turbo Stream).
def toast_demo
  render turbo_stream: helpers.toast_stream("검토 요청을 보냈어요", tone: "good")
end

# 보내고 돌아오는 흐름은 flash 로. 레이아웃이 토스트로 바꿔 띄운다.
redirect_to design_system_path(anchor: "overlays"), notice: "설정을 저장했어요"
bash script/ds/lint_ui.sh app/views/design_system

머리 없는 한 줄 명령어는 code--bare, 줄을 바꿔 보여야 하면 code--wrap 이에요.

ERB
<%# 이 화면(디자인 시스템)에서는 sg_code 가 키트 마크업을 그린다 %>
<%= sg_code :display_code_sample, lang: "Ruby" %>

<%# 서비스 화면에서는 키트 마크업. 복사는 ds.js·extras.js 가 한다 %>
<div class="code">
  <div class="code__head">
    <span class="code__lang">Ruby</span>
    <button class="code__copy" type="button">
      <%= icon "copy", class: "code__copy-ico" %><%= icon "check", class: "code__copy-done" %>
      <span class="code__copy-label">복사</span>
    </button>
  </div>
  <pre class="code__body" tabindex="0"><code>…</code></pre>
</div>
<%# 머리 없는 한 줄 명령어: code--bare · 줄 바꿈: code--wrap %>

가격

숫자가 주인공이에요. 단위·기간은 한 단계 작게, 정가는 위에 흐리게 긋고 화면 읽기용 '정가'를 숨겨 붙여요.

예시 요금

정가 월 25,000원 19,000원/ 월 24% 할인 연구원 1명 기준 · 부가세 포함

228,000원/ 년

0원첫 달

ERB
<p class="price">
  <span class="price__was"><span class="sr-only">정가 </span>월 25,000원</span>
  <span class="price__num">19,000</span>
  <span class="price__unit">원</span>
  <span class="price__per">/ 월</span>
  <span class="price__save">24% 할인</span>
  <span class="price__note">연구원 1명 기준 · 부가세 포함</span>
</p>
<%# 크기: price--lg · price--sm · 약하게: price--muted(무료·첫 달) %>

외부 서비스로 계속하기

그 서비스를 가리킬 때만 로고(provider_mark)를 써요. 색과 비율을 바꾸지 않아요. 누르면 로그인 화면 예시로 가요.

ERB
<%# provider_mark("kakao"|"google"|"telegram"|"whatsapp", size:). 그 서비스를 가리킬 때만 쓴다 %>
<div class="auth__social">
  <%= link_to "/login", class: "btn btn--outline btn--lg btn--block" do %>
    <%= provider_mark "kakao", size: 20 %><span class="btn__label">카카오로 계속하기</span>
  <% end %>
  <%= link_to "/login", class: "btn btn--outline btn--lg btn--block" do %>
    <%= provider_mark "google", size: 20 %><span class="btn__label">Google로 계속하기</span>
  <% end %>
</div>

수 배지

확인할 일의 수를 메뉴 옆·아이콘 위에 보여요. 0 이면 그리지 않고, 99 를 넘으면 99+. 수는 버튼 이름에도 함께 읽혀요.

ERB
<%# 앱바 버튼: count 가 0 이면 그리지 않고, 99 를 넘으면 99+. 이름에 수를 함께 읽는다('알림 3건') %>
<%= appbar_action "bell", label: "알림", count: 3 %>
<%= appbar_action "chat", label: "문의", count: 120 %>

<%# 메뉴 옆 개수(관리자 사이드바). --bad 처리할 것 · --live 지금 진행 중 %>
<a class="admin__nav-item" href="/admin/signatures">
  <%= icon "shield", size: "sm" %><span class="admin__nav-text">서명 요청</span>
  <span class="admin__nav-count admin__nav-count--bad" aria-label="3건">3</span>
</a>

시간 표시

지난 일은 ago 로 '5분 전', 정해진 시각은 ko_time_tag 로 '9/16(수) 19:00'. 둘 다 <time> 에 기계가 읽는 값을 함께 둬요.

  • 박도윤 책임연구원이 검토를 마쳤어요
  • 김지수 연구원이 실험 노트를 고쳤어요
  • 이서연 연구원이 첨부 파일 2개를 올렸어요
  • 한빛바이오 연구소와 노트를 공유했어요
  • 과제 '세포 배양 조건 비교'를 만들었어요

검토 기한

ERB
<%# 지난 시각: ago(t) → 방금 · 5분 전 · 3시간 전 · 2일 전 · 일주일이 넘으면 날짜 %>
<time datetime="<%= note.updated_at.iso8601 %>"
      title="<%= ko_time(note.updated_at) %>"><%= ago(note.updated_at) %></time>

<%# 정해진 시각: ko_time_tag(t) %>
<%# → <time datetime="2026-09-16T19:00:00+09:00">9/16(수) 19:00</time> %>
검토 기한 <%= ko_time_tag(review.due_at) %>

숫자·날짜 서식

금액·날짜·전화번호는 헬퍼가 같은 모양을 만들어요. 직접 조립하지 않아요. 오른쪽 결과는 지금 헬퍼가 실제로 그린 값이에요.

헬퍼결과쓰는 곳
krw(12000) 12,000원 표·카드·버튼의 금액
krw(nil) - 금액이 없을 때
krw_words(45000) 4만 5,000원 문장 속 큰 금액
krw_words(100000) 10만 원 만 단위로 떨어질 때
ko_time(t) 9/16(수) 19:00 목록·카드의 날짜와 시각(24시간)
ko_time(t, with_date: false) 19:00 시각만
ko_date(t) 9/16(수) 날짜만
ko_date(작년 12월 30일) 2025. 12/30(화) 올해가 아니면 연도를 앞에
ago(5분 전) 5분 전 지난 시각
ko_phone(휴대폰) 010-0000-1234 "01000001234" 를 3-4-4 로. 예시 번호
ko_phone(대표 번호) 070-0000-0000 "07000000000" 을 3-4-4 로. 예시 번호
ERB
<%= krw(12000) %>              <%# 12,000원 · krw(nil) → - %>
<%= krw_words(45000) %>        <%# 4만 5,000원(문장 속) %>
<%= ko_time(t) %>              <%# 9/16(수) 19:00 · with_date: false → 19:00 %>
<%= ko_date(t) %>              <%# 9/16(수) · 올해가 아니면 2025. 12/30(화) %>
<%= ago(t) %>                  <%# 5분 전 %>
<%= ko_phone("01000001234") %> <%# 010-0000-1234 %>

09입력

적기 쉽고, 틀려도 바로 고치게

입력칸은 버튼과 같은 44px, 글자는 16px 이에요. 오류는 빨간 테두리·문장·aria 세 가지로 함께 알려요.

한 줄 입력의 상태

평소에는 옅은 면, 초점이 오면 흰 면과 강조 테두리. 상태가 바뀌어도 칸 크기는 그대로예요.

빈 값 · 자리 표시
채움
초점(강제로 표시)
도움말

검토를 맡기면 이 주소로 알려 드려요

읽기 전용(readonly)

고칠 수 없지만 복사할 수 있어요

비활성(disabled)

서명한 노트는 바꿀 수 없어요

오류(aria-invalid + field__error)

숫자 4자리를 적어 주세요. 예: S-0024

오류 + 초점(강제로 표시)

숫자 4자리를 적어 주세요. 예: S-0024

초점 두 칸은 모양만 보여 주려고 강제로 칠했어요. 다른 칸을 누르거나 Tab 으로 옮겨 실제 초점을 보세요. 오류 문장 앞 아이콘은 키트가 저절로 붙여요.

ERB
<%# 모델 폼: 빌더가 라벨·입력·오류·도움말과 aria-invalid·aria-describedby 를 함께 그려요 %>
<%= ds_form_with model: @note do |f| %>
  <%= f.text_field :title, placeholder: "예: 전해질 농도별 전도도 측정" %>
  <%= f.email_field :reviewer_email, help: "검토를 맡기면 이 주소로 알려 드려요" %>
  <%= f.text_field :number, readonly: true, help: "고칠 수 없지만 복사할 수 있어요" %>
  <%= f.text_field :signer, disabled: true %>
<% end %>

<%# 폼 객체가 없는 칸 %>
<%= ui_input :sample, "S-24", label: "시료 번호", error: "숫자 4자리를 적어 주세요. 예: S-0024" %>

<!-- 그려지는 마크업(오류) -->
<div class="field">
  <label class="field__label" for="sample">시료 번호</label>
  <input class="input" id="sample" aria-invalid="true" aria-describedby="sample-error">
  <p class="field__error" id="sample-error">숫자 4자리를 적어 주세요. 예: S-0024</p>
</div>

크기 세 가지

높이만 36·44·52px 로 바뀌고 글자는 모두 16px 이에요. 16px 보다 작으면 iOS 가 초점 때 화면을 확대해요.

ERB
<%= f.text_field :filter, size: "sm" %>   <%# .input--sm 36px %>
<%= f.text_field :title %>                <%# 기본 44px %>
<%= f.text_field :title, size: "lg" %>    <%# .input--lg 52px, 한 질문 화면 %>

<input class="input input--sm">  <input class="input">  <input class="input input--lg">

앞뒤 칸

앞에는 무엇을 적는지 알리는 아이콘, 뒤에는 단위나 지우기·비밀번호 보기 버튼을 둬요. 뒤 칸 폭만큼 여백이 저절로 잡혀요.

원
mL

전자서명할 때 한 번 더 물어요

ERB
<!-- 앞 아이콘 + 지우기(extras.js 가 뒤 칸에 버튼을 넣어요) -->
<div class="input-group">
  <span class="input-group__icon" aria-hidden="true"><%= icon("pin", size: "sm") %></span>
  <input class="input" id="place" data-clearable>
</div>

<!-- 뒤 단위 -->
<div class="input-group">
  <input class="input" id="fee" inputmode="numeric" value="120,000">
  <span class="input-group__end"><span class="input-group__addon">원</span></span>
</div>

<!-- 비밀번호 보기(aria-pressed 와 이름이 함께 바뀌어요) -->
<div class="input-group">
  <input class="input" id="pw" type="password" data-password-toggle autocomplete="new-password">
</div>

찾기 칸

목록 위에는 알약 모양 검색 바, 폼 안에서는 찾기 칸을 써요. 둘 다 값이 있을 때만 지우기가 보이고 Escape 로도 비워요.

검색 바(.searchbar) · 목록 위

이름이나 소속 연구실로 찾아요

ERB
<!-- 목록 위 검색 바: 지우기 버튼은 app.js 가 만들어요. data-clearable 을 붙이지 않아요 -->
<div class="searchbar" role="search">
  <%= icon("search", size: "sm") %>
  <input class="searchbar__input" type="search" placeholder="노트 제목·시료 번호로 찾기" aria-label="연구노트 찾기">
</div>

<!-- 폼 안 찾기 칸 -->
<%= f.search_field :researcher, label: "연구원 찾기", help: "이름이나 소속 연구실로 찾아요" %>

여러 줄

적는 만큼 칸이 늘어나요(최대 320px). 코드·JSON 은 textarea.mono 로 줄 높이를 고정하고 손잡이를 둬요.

줄을 더 적어 보세요. 칸이 따라 늘어나요

ERB
<%= f.text_area :process, rows: 3 %>                               <%# 적는 만큼 늘어나요(field-sizing) %>
<%= f.text_area :conditions, rows: 5, class: "mono", spellcheck: false %>  <%# 코드·JSON: 높이 고정 + 손잡이 %>

입력 도우미 · 글자 수

속성 하나로 '43 / 80' 을 붙여요. 90% 부터 주의색, 넘치면 오류색과 aria-invalid. 지우기·비밀번호 보기도 같은 도우미예요.

검토자에게만 보여요

20자를 넘으면 목록에서 잘려요. 줄여 보세요

ERB
<%# 글자 수: maxlength 를 한도로 '43 / 80'. 도움말이 있으면 같은 줄에 붙어요 %>
<%= f.text_area :memo, maxlength: 80, data: { maxlength_count: "" }, help: "검토자에게만 보여요" %>

<%# 한도를 넘겨 적을 수 있게 두고 넘치면 오류색 + aria-invalid %>
<%= f.text_field :short_name, data: { maxlength_count: 20 } %>

<%# 지우기·비밀번호 보기도 같은 도우미예요(.input-group 안) %>
<input class="input" type="search" data-clearable>
<input class="input" type="password" data-password-toggle>

슬라이더

범위 안에서 대충 고르는 값에 써요. 채운 트랙과 옆 값이 손을 따라 바로 바뀌고, 화면 읽기도 같은 글을 읽어요.

장비 예약 시간 안에는 8회까지 잴 수 있어요

ERB
<div class="range-field">
  <div class="range-field__head">
    <label class="field__label" for="temp">배양 온도</label>
    <output class="range-field__value" for="temp"></output>
  </div>
  <input class="range" id="temp" type="range" min="4" max="42" step="0.5" value="37" data-range-format="{v}°C">
  <div class="range-field__scale" aria-hidden="true"><span>4°C</span><span>42°C</span></div>
</div>

<input class="range" type="range" min="0" max="3" data-range-labels="느리게,보통,빠르게,최대">
<input class="range" type="range" aria-invalid="true" aria-describedby="rep-error">  <%# 오류 %>
<input class="range" type="range" disabled>                                      <%# 비활성 %>

선택기

네이티브 select 대신 목록까지 직접 그려요. ↓·↑ 로 열고 글자를 치면 그 글자로 건너뛰어요. 640px 보다 좁으면 아래에서 올라오는 시트가 돼요.

기본
연구 과제
찾기 칸(data-picker-search)
시약

목록이 길면 맨 위에 찾기 칸을 둬요

묶음 제목 · 설명 줄 · 비활성 항목
측정 장비
오류(고르면 저절로 풀려요)
검토자

검토를 맡을 책임연구원을 골라 주세요

비활성
승인자

연구소 규정으로 정해져 있어요

크기 sm · 기본 · lg
조회 기간
조회 기간
조회 기간

휴대폰에서 열면 목록이 하단 시트가 되고 맨 위에 칸 이름(data-menu-title)이 붙어요. 뒤 화면은 잠기고, Escape 는 목록만 닫아요.

ERB
<%# 빌더: 값은 숨은 입력으로 함께 보내요. 칸 이름이 좁은 화면 시트 제목(data-menu-title)이 돼요 %>
<%= f.picker :project, DesignSystem::SampleNote::PROJECTS, placeholder: "과제를 골라 주세요" %>
<%= f.picker :reagent, reagents, "data-picker-search": "시약 이름이나 화학식",
             "data-picker-empty": "찾는 시약이 없어요. 이름을 줄여 보세요" %>
<%= f.picker :range, periods, size: "sm" %>

<!-- 묶음 제목 · 설명 줄 · 비활성 항목(빌더에 없어 마크업으로) -->
<div class="picker__list" role="listbox" hidden>
  <div role="group" aria-labelledby="g-a">
    <div class="picker__group" id="g-a">분석실 A</div>
    <button class="picker__opt" type="button" role="option" data-value="uvvis" data-label="UV-Vis 분광광도계" aria-selected="false">
      <span class="picker__opt-text">UV-Vis 분광광도계<span class="picker__opt-sub">오늘 14시까지 비어 있어요</span></span>
      <%= icon("check", size: "sm", class: "picker__opt-mark") %>
    </button>
    <button class="picker__opt" type="button" role="option" data-value="ftir" disabled>…</button>
  </div>
</div>

<%# 오류: 버튼에 aria-invalid="true"(고르면 ds.js 가 거둬요) + 아래 .field__error %>

네이티브 select 는 긴 목록에서만

나라·연도처럼 항목이 수십 개이고 휴대폰 기본 휠이 더 빠른 곳에만 써요. 그 밖에는 선택기를 써요.

목록은 기기가 그려요. 모양은 입력칸과 같아요

ERB
<%# 나라·연도처럼 긴 목록만. 줄 끝에 lint 예외와 이유를 적어요 %>
<%= f.select :country, countries, {}, {} %> <%# ds-lint-ignore:native-select 나라 목록은 OS 휠이 빨라요 %>

<select class="select" id="country">…</select>

분절

두세 개 중 하나를 바로 바꿔 볼 때 써요. 고른 칸은 띄운 면·그림자·진한 글자로 알리고 화살표로 옮겨요.

기본 · 비활성 칸
가로 전체(segment--block)
작게(segment--sm)
ERB
<div class="segment" role="group" aria-label="농도 단위">
  <button class="segment__item" type="button" aria-pressed="true">mM</button>
  <button class="segment__item" type="button" aria-pressed="false">M</button>
  <button class="segment__item" type="button" disabled>ppm</button>
</div>

<div class="segment segment--block" role="group" aria-label="검토 상태">…</div>  <%# 가로 전체 %>
<div class="segment segment--sm" role="group" aria-label="기간">…</div>          <%# 작게 %>
<%# 고르면 ds:segment 이벤트(detail.value) %>

체크 · 라디오

체크는 여러 개를 켜고 끄고, 라디오는 하나만 골라요. 상자는 직접 그려서 기기마다 모양이 같고, 긴 라벨은 첫 줄에 맞춰요.

내보낼 때 함께 넣기

내보내기 전에 보안 서약을 확인해 주세요

공개 범위
ERB
<%= f.check_box :agree, label: "기록 원칙을 확인했어요" %>
<%= f.radio_group :visibility, [["과제 팀", "team"], ["나만 보기", "private"]] %>

<label class="choice">
  <input type="checkbox" checked>
  <span class="choice__text">페이지마다 날짜와 서명 찍기
    <span class="choice__sub">PDF 로 보관할 때 원본임을 보여 줘요</span></span>
</label>
<label class="choice"><input type="checkbox" aria-invalid="true" aria-describedby="pledge-error">…</label>
<p class="field__error" id="pledge-error">내보내기 전에 보안 서약을 확인해 주세요</p>

스위치

누르는 즉시 반영되는 설정에 써요. 보내야 반영되는 동의·약관은 체크를 써요. 켜짐은 색과 손잡이 위치로 함께 알려요.

연구소 규정이라 끌 수 없어요

ERB
<%= f.switch :notify, label: "검토 요청이 오면 알림", help: "연구소 규정이라 끌 수 없어요" %>

<label class="switch">
  <input type="checkbox" role="switch" checked>
  <span class="switch__track"></span><span class="switch__knob"></span>
  <span class="t-body-sm ink-body">검토 요청이 오면 알림</span>
</label>

선택 칩

짧은 선택지를 한눈에 늘어놓을 때 써요. 여러 개 고르기(data-multi)는 고른 칩 앞에 체크가 붙고, 칩 안의 수는 한 단계 흐려요.

하나만 · 반복 횟수
여러 개(data-multi) · 첨부할 자료
두 칸 격자(choices--grid)
ERB
<div class="choices" role="group" aria-label="반복 횟수">
  <button class="choices__item" type="button" aria-pressed="true">3회</button>
  <button class="choices__item" type="button" aria-pressed="false" disabled>10회</button>
</div>

<%# 여러 개: 고른 칩 앞에 체크, 칩 안의 수는 흐리게(extras) %>
<div class="choices" data-multi role="group" aria-label="첨부할 자료">
  <button class="choices__item" type="button" aria-pressed="true">원본 데이터 <span class="choices__count">12</span></button>
</div>
<div class="choices choices--grid" data-multi>…</div>   <%# 두 칸 격자 · --wrap · --stack %>
<%# 고르면 ds:choice 이벤트 %>

수량

작은 정수를 하나씩 늘리고 줄일 때 써요. 끝에 닿으면 그쪽 버튼이 비활성이 되고, 바뀐 값은 화면 읽기가 읽어요.

시료 개수1~24개 6
반복 측정장비 예약 안에서 5회까지 5
참여 연구원나를 포함해 1명부터 1
ERB
<span class="stepper-num" data-min="1" data-max="24" role="group" aria-labelledby="samples-label">
  <input type="hidden" name="note[samples]" value="6">
  <button class="stepper-num__btn" type="button" aria-label="시료 한 개 줄이기"><%= icon("minus", size: "sm") %></button>
  <span class="stepper-num__val">6</span>
  <button class="stepper-num__btn" type="button" aria-label="시료 한 개 늘리기"><%= icon("plus", size: "sm") %></button>
</span>
<%# 바뀌면 stepper:change 이벤트(detail.value), 숨은 입력도 함께 바뀌어요 %>

인증번호 6칸

칸이 나뉜 것처럼 보이지만 입력은 하나라 붙여 넣기와 문자 자동 채우기가 돼요. 틀리면 칸이 빨개지고 한 번 흔들려요.

전자서명 인증번호 6자리

예시 번호 280914 를 넣으면 맞아요. 다른 번호는 틀린 모습을 보여 줘요

ERB
<div class="otp">
  <input class="otp__input" aria-labelledby="otp-label" aria-describedby="otp-error">
  <span class="otp__cell"></span><span class="otp__cell"></span><span class="otp__cell"></span>
  <span class="otp__cell"></span><span class="otp__cell"></span><span class="otp__cell"></span>
</div>
<p class="field__error" id="otp-error" hidden></p>
<%# app.js 가 inputmode=numeric·autocomplete=one-time-code 를 붙여요. 다 채우면 otp:done(detail.value).
    틀리면 입력에 aria-invalid="true"(칸이 빨개지고 흔들림), 다시 적으면 저절로 풀려요 %>

금액

금액은 큰 숫자 한 줄로 받고 쉼표는 저절로 찍어요. 빠른 더하기 버튼으로 자판 없이도 채워요.

원 남은 연구비 3,480,000원

+100만을 네 번 눌러 남은 연구비를 넘겨 보세요. 숫자와 안내 줄이 함께 빨개져요.

ERB
<div class="amount">
  <label class="amount__label" for="spend">이번에 집행할 연구비</label>
  <span class="amount__field">
    <input class="amount__input" id="spend" aria-describedby="spend-hint">
    <span class="amount__unit">원</span>
  </span>
  <span class="amount__hint" id="spend-hint" aria-live="polite">남은 연구비 3,480,000원</span>
  <span class="amount__quick">
    <button class="btn btn--secondary btn--sm" type="button" data-amount-add="10000">+1만</button>
    <button class="btn btn--ghost btn--sm" type="button" data-amount-add="0">지우기</button>
  </span>
</div>
<%# 쉼표는 app.js 가 찍고 amount:change(detail.value) 를 쏴요. 한도를 넘으면 입력에 aria-invalid="true" %>

날짜

폼 안에서는 date_field 로 기기 날짜 휠을 쓰고, 날짜가 화면의 주인공이면 키트 달력을 펼쳐 둬요. 달력에서 고르면 옆 칸도 따라 바뀌어요.

휴대폰에서는 기기 날짜 휠이 떠요. 오늘 뒤로는 고를 수 없어요

고른 날 9월 26일 토요일

달력의 점은 이미 노트가 있는 날, 굵은 파랑 숫자는 오늘이에요. 날짜 칸 하나만 Tab 으로 들어가고 화살표·PageUp·PageDown 으로 옮겨요.

ERB
<%# 폼 안: 기기 날짜 휠 %>
<%= f.date_field :experiment_on, max: Date.current.iso8601 %>

<%# 날짜가 주인공일 때: 키트 달력(app.js). 오늘·일정 점·고를 수 있는 범위 %>
<div class="cal" data-cal data-today="2026-09-28" data-max="2026-09-28"
     data-value="2026-09-26" data-marks="2026-09-19,2026-09-23" aria-label="실험 날짜 달력"></div>
<%# 고르면 cal:change(detail.date). 기간은 data-mode="range" %>

파일 첨부

끌어다 놓거나 눌러서 골라요. 올린 파일은 아래 칩으로 쌓이고, 칩의 x 로 뺄 수 있어요.

파일을 끌어다 놓으세요 또는 눌러서 고르기 · PDF·사진·CSV
전도도_측정값_0928.csv 3번_시료_필름_사진_확대_200배_최종본.jpg
ERB
<div class="drop" data-drop="#files" tabindex="0" role="button" aria-label="측정 데이터 파일 올리기">
  <span class="drop__icon"><%= icon("folder", size: "lg") %></span>
  <span class="t-body-sm w-600 ink-body">파일을 끌어다 놓으세요</span>
  <span class="t-caption ink-muted">또는 눌러서 고르기 · PDF·사진·CSV</span>
  <input type="file" multiple hidden accept=".pdf,.jpg,.png,.csv">
</div>
<div id="files"></div>
<%# 고른 파일은 app.js 가 .filechip(이름 + 빼기 버튼)으로 #files 에 붙이고 drop:files 를 쏴요 %>

칸 배치

라벨은 위, 오류는 입력 바로 아래, 도움말은 맨 아래에 둬요. 두 칸을 나란히 두는 건 넓은 화면에서만이에요.

숫자 4자리를 적어 주세요. 예: S-0024

시료 병 라벨에 적힌 번호예요

  1. 라벨명사로 짧게. 선택 항목에만 '(선택)'
  2. 입력칸자리 표시에는 예시만
  3. 오류입력 바로 아래. 원인과 할 일
  4. 도움말맨 아래. 적기 전에 알아야 할 것

위 두 칸은 768px 부터 나란히, 좁은 화면에서는 한 줄에 한 칸이에요. 휴대폰 자판이 화면 절반을 가려서 옆 칸이 안 보이기 때문이에요.

ERB
<%# 라벨 → 입력 → 오류 → 도움말. 빌더가 이 순서로 그려요 %>
<div class="field">
  <label class="field__label" for="sample">시료 번호</label>
  <input class="input" id="sample" aria-invalid="true" aria-describedby="sample-error sample-help">
  <p class="field__error" id="sample-error">숫자 4자리를 적어 주세요. 예: S-0024</p>
  <p class="field__help" id="sample-help">시료 병 라벨에 적힌 번호예요</p>
</div>

<%# 두 칸 나란히는 넓은 화면에서만(화면 전용 CSS) %>
.pair{display:grid;gap:var(--s-16)}
@media (min-width:768px){.pair{grid-template-columns:repeat(2,minmax(0,1fr))}}

검증 시점

적는 중에는 검사하지 않고, 칸을 떠날 때 형식을, 보낼 때 전부 봐요. 오류 난 칸은 고치는 대로 다시 봐서 맞으면 바로 지워요.

언제무엇을
적는 중검사하지 않아요. 다 적기 전에 빨간 글씨가 뜨면 혼나는 기분이에요
칸을 떠날 때형식만 봐요(이메일 모양, 번호 자리 수)
오류 칸을 고칠 때적는 대로 다시 보고, 맞으면 바로 오류를 지워요
보낼 때전부 보고 첫 오류 칸으로 초점을 옮겨요
서버 응답서버만 아는 오류(앞날 날짜·중복)는 그 칸 아래에. 토스트로 흘리지 않아요

직접 해 보기 · 연구원 초대

'jisu' 까지만 적고 칸을 떠나 보세요

비운 채 누르면 첫 오류 칸으로 초점이 가요
ERB
// 칸을 떠날 때: 비어 있지 않은데 형식이 틀리면 알린다
input.addEventListener("focusout", () => {
  if (input.value.trim() && !input.checkValidity()) showError(input, "이메일 주소에 @ 가 빠졌어요")
})
// 오류 칸을 고칠 때: 맞는 순간 지운다(오류가 없던 칸은 적는 중에 검사하지 않는다)
input.addEventListener("input", () => {
  if (input.getAttribute("aria-invalid") === "true" && input.checkValidity()) clearError(input)
})
// 보낼 때: 전부 보고 첫 오류 칸에 초점
const first = [...form.elements].find((el) => el.willValidate && !el.checkValidity())
first?.focus()

오류 문구

오류는 원인과 할 일을 한 문장으로 말해요. 사용자를 탓하지 않고, 형식은 예시로 보여 줘요.

이렇게 말고이렇게
필수 입력 항목입니다노트 제목을 적어 주세요
유효하지 않은 이메일입니다이메일 주소에 @ 가 빠졌어요
잘못 입력하셨습니다숫자 4자리를 적어 주세요. 예: S-0024
날짜 오류앞날은 고를 수 없어요. 실험한 날짜를 골라 주세요
파일 크기 초과20MB 까지 올릴 수 있어요. 지금 파일은 34MB 예요
저장 실패저장하지 못했어요. 적은 내용은 그대로 두었어요
Error 500잠깐 문제가 생겼어요. 1분 뒤 다시 눌러 주세요

입력 오류는 토스트로 알리지 않고 칸 아래에 둬요. 한 칸에 오류가 여럿이면 먼저 고칠 것 하나만 보여요.

ERB
# 모델 검증 문구: 칸 아래에 붙으므로 칸 이름을 되풀이하지 않고 원인과 할 일만
validates :title, presence: { message: "노트 제목을 적어 주세요" }
validates :reviewer_email, format: { with: URI::MailTo::EMAIL_REGEXP,
  message: "이메일 주소 모양이 아니에요. @ 뒤까지 적어 주세요" }, allow_blank: true
errors.add(:experiment_on, "앞날은 고를 수 없어요. 실험한 날짜를 골라 주세요")

실제로 보내는 폼

폼 빌더(DsFormBuilder)의 칸을 모두 쓴 연구노트 기록 폼이에요. 서버가 검사하고 Turbo 로 이 폼만 다시 그려요. 예시라 저장하지 않아요.

연구 과제

검토를 맡길 책임연구원의 주소예요

공개 범위
ERB
<%# app/views/design_system/_form_demo.html.erb %>
<div id="sg-form-demo">
  <%= ds_form_with model: note, url: design_system_form_path, scope: :sample_note, html: { novalidate: true } do |f| %>
    <%= f.error_summary %>
    <%= f.text_field :title, maxlength: 60, data: { maxlength_count: "" } %>
    <%= f.picker :project, DesignSystem::SampleNote::PROJECTS, placeholder: "과제를 골라 주세요" %>
    <%= f.date_field :experiment_on, max: Date.current.iso8601 %>
    <%= f.text_area :summary, optional: true %>
    <%= f.email_field :reviewer_email, optional: true, help: "검토를 맡길 책임연구원의 주소예요" %>
    <%= f.radio_group :visibility, DesignSystem::SampleNote::VISIBILITIES, inline: true %>
    <%= f.switch :notify, label: "검토 의견이 달리면 알림 받기" %>
    <%= f.check_box :agree, label: "기록 원칙을 확인했어요" %>
    <%= f.submit "기록하기", block: true %>
  <% end %>
</div>

# app/controllers/design_system_controller.rb
def form_demo
  @note = DesignSystem::SampleNote.new(note_params)
  if @note.valid?
    render turbo_stream: [
      turbo_stream.replace("sg-form-demo", partial: "design_system/form_demo", locals: { note: DesignSystem::SampleNote.new }),
      helpers.toast_stream("연구노트를 기록했어요", tone: "good")
    ]
  else
    render turbo_stream: turbo_stream.replace("sg-form-demo", partial: "design_system/form_demo", locals: { note: @note }),
           status: :unprocessable_entity
  end
end

10데이터

카드·목록·표

기록을 담는 그릇을 모았어요. 한 덩어리는 카드, 이어지는 것은 목록, 나란히 비교할 것은 표로 보여 줘요.

카드

카드 변형

한 덩어리로 읽히는 정보에만 써요. 바탕과 면이 다르면 테두리 없이, 같은 면 위에서는 선 하나나 그림자 하나로 나눠요.

card · 면이 다를 때
항체 개발검토 대기

PCR 증폭 조건 정리

어닐링 온도 58℃에서 밴드가 가장 선명했어요.

김지수 연구원 · 9/28(월)

card--line · 같은 면 위
세포주 안정화초안

시약 로트를 바꿨어요

LOT-2409-17로 바꾸고 첫 측정값을 남겼어요.

최민준 연구원 · 9/26(토)

card--raise · 살짝 뜸
검토 요청

3건이 검토를 기다려요

가장 오래된 요청은 2일 전에 왔어요.

박이정
card--float · 겹쳐 뜸
서명을 마쳤어요 NB-2409-028 · 10:05
card--sm · 작은 칸
첨부 파일 4개 gel_0924.tif 외 3개 · 18.2MB
클래스언제 쓰나
card바탕과 면 색이 다를 때. 테두리 없이 면으로만 나눠요
card--line카드와 바탕이 같은 색일 때 가는 테두리를 둘러요
card--raise바탕 위로 살짝 떠야 할 때. 옅은 그림자예요
card--float다른 내용 위에 겹쳐 뜰 때. 목업 옆 조각이나 알림 조각에 써요
card--link카드 전체가 링크일 때. 올리면 3px 떠요
card--flush여백 없이 사진·목록을 끝까지 채울 때
card--sm card--lg안쪽 여백과 모서리를 작게 또는 크게 바꿔요

테두리와 그림자는 함께 쓰지 않아요. 누를 수 있는 카드는 선 대신 옅은 그림자에 card--link 를 더해요.

HTML
<!-- 바탕과 면이 다를 때: 테두리 없이 면으로 -->
<article class="card">…</article>

<!-- 같은 면 위: 선 하나 또는 그림자 하나(둘을 함께 쓰지 않는다) -->
<article class="card card--line">…</article>
<article class="card card--raise">…</article>
<article class="card card--float card--sm">…</article>

<!-- 카드 전체가 링크: 올리면 3px 뜬다 -->
<a class="card card--raise card--link" href="/app/notes">
  <span class="t-caption ink-muted">분석법 검증</span>
  <span class="t-heading-sm ink">HPLC 분리능 비교</span>
  <span class="t-body-sm ink-muted">컬럼 온도를 35℃로 올려 다시 쟀어요.</span>
</a>

<!-- 크기: card--sm(여백 16) · 기본(24) · card--lg(32) -->

미디어 카드

첨부 사진·그래프를 미리 볼 때 써요. card--flush 로 여백 없이 채우고, 여러 장은 가로 띠(rail)로 넘겨요.

이미지

PCR 산물 전기영동

3번 레인의 밴드가 흐려 다시 찍었어요.

관찰

세포 배양 7일차

밀도가 80%에 가까워요. 내일 계대해요.

그래프

흡광도 표준 곡선

5개 농도로 그렸고 R² 0.998이에요.

HTML
<div class="rail" tabindex="0" role="region" aria-label="첨부 미리보기, 옆으로 밀어 보세요">
  <article class="card card--flush">
    <div class="card__media">
      <img src="…" alt="PCR 산물 전기영동 사진, 레인 6개">
    </div>
    <div class="card__body">
      <span class="badge badge--neutral">이미지</span>
      <p class="t-heading-sm ink">PCR 산물 전기영동</p>
      <p class="t-body-sm ink-muted">3번 레인의 밴드가 흐려 다시 찍었어요.</p>
    </div>
  </article>
  …
</div>

특징 목록

기능이나 약속을 나열할 때 써요. 카드로 감싸지 않고 아이콘과 글자만 둬요.

예시 문구

서명하면 잠겨요

전자서명한 노트는 고칠 수 없어요. 바꿀 게 있으면 새 버전으로 남겨요.

바꾼 기록이 모두 남아요

누가 언제 무엇을 바꿨는지 노트마다 볼 수 있어요.

검토는 노트 옆에서

책임연구원이 문단마다 댓글을 달고 바로 답을 받아요.

첨부는 원본 그대로

장비에서 나온 파일을 바꾸지 않고 노트에 붙여 둬요.

HTML
<!-- 카드로 감싸지 않는다. 아이콘 · 제목 · 설명만 -->
<div class="features">
  <div class="feature">
    <span class="feature__icon" aria-hidden="true"><%= icon "lock" %></span>
    <p class="feature__title">서명하면 잠겨요</p>
    <p class="feature__desc">전자서명한 노트는 고칠 수 없어요. 바꿀 게 있으면 새 버전으로 남겨요.</p>
  </div>
  …
</div>

목록

목록 행

노트·사람·파일처럼 같은 모양이 이어질 때 써요. 앞(아이콘·아바타)·가운데(제목·부제)·뒤(배지·수·시간) 세 자리로 나눠요.

ERB
<div class="group list list--inset-divider">
  <%# 앞 아이콘 · 제목 · 부제 · 뒤 배지 · 화살표(링크) %>
  <%= row_item title: "PCR 증폭 조건 최적화 3차", sub: "항체 개발 · 김지수 연구원",
               lead: icon("doc"), tail: ui_badge("검토 대기", "amber"),
               href: note_path(note), chev: true, class: "pressable pressable--flat" %>

  <%# 뒤 칸에 시간(ago)만 두고 누르지 않는 행 %>
  <%= row_item title: "측정값 표를 고쳤어요", sub: "NB-2409-025 · 이서연 선임연구원",
               lead: icon("edit"), tail: ago(change.created_at) %>

  <%# 높은 행(76px) %>
  <%= row_item title: "세포주 안정화 과제", sub: "노트 18개 · 9/28(월) 마지막 기록",
               lead: icon("folder"), tail: ui_badge("진행 중", "accent"), href: project_path(p), chev: true, tall: true %>
</div>

<%# 둥근 아바타 앞 칸은 헬퍼에 수식이 없어 마크업으로 쓴다 %>
<a class="row-item pressable pressable--flat" href="…">
  <span class="row-item__lead row-item__lead--round row-item__lead--accent" aria-hidden="true">박</span>
  <span class="row-item__main"><span class="row-item__title">박도윤 책임연구원</span><span class="row-item__sub">세포치료팀 · 검토 요청</span></span>
  <span class="row-item__tail"><span class="row-item__value">2건</span></span>
  <span class="row-item__chev"><%= icon "chevron-right" %></span>
</a>

묶음 목록

목록에 제목과 '모두 보기'를 붙여 묶을 때 써요. 구분선은 앞 칸만큼 들여 쓰거나(기본) 끝까지 그어요.

오늘 기록

모두 보기

첨부 파일

gel_0924.tif이미지 · 9/28(월) 14:1812.4MB
qpcr_standard.csv측정값 표 · 9/25(금) 16:1084KB

구분선을 끝까지 그은 목록(list--divided)이에요.

ERB
<%# 제목 + '모두 보기' + 앞 칸만큼 들여 쓴 구분선(기본) %>
<%= ui_group "오늘 기록", more: [ "모두 보기", notes_path ] do %>
  <%= row_item title: "웨스턴 블롯 항체 희석 비교", sub: "11:02 · 정하은 연구원", lead: icon("doc"),
               tail: ui_badge("검토 대기", "amber"), href: note_path(n), chev: true %>
  …
<% end %>

<%# 끝까지 그은 구분선 %>
<%= ui_group "첨부 파일", divider: :full do %>
  <%= row_item title: "gel_0924.tif", sub: "이미지 · 9/28(월) 14:18", lead: icon("image"), tail: "12.4MB" %>
<% end %>

<%# 헬퍼는 묶음 제목을 h2 로 그린다(앱 화면 기준). 이 검토 화면에서는 제목 차례를 맞추려고 h4 로 그렸다. %>

숫자

숫자 카드

대시보드 첫 줄에서 지금 상태를 숫자로 보여 줄 때 써요. 늘고 준 것은 색만이 아니라 부호와 화살표로 함께 알려요.

이번 주 기록 128건 +12%지난주보다 검토 대기 5건 가장 오래된 요청 2일 전
서명 완료 92% +4%p이번 달
평균 검토 시간 1.8일 −0.3일줄었어요
첨부 파일 342개 변화 없음4.2GB
참여 연구원 14명 전체 16명 중
HTML
<section class="kpi" aria-label="이번 주 연구노트 숫자">
  <a class="kpi__tile pressable" href="#notes">
    <span class="kpi__label">이번 주 기록</span>
    <span class="kpi__value">128<span class="kpi__unit">건</span></span>
    <span class="kpi__foot">
      <span class="kpi__delta kpi__delta--good"><%= icon "arrow-up" %>+12%</span>
      <span class="kpi__sub">지난주보다</span>
    </span>
  </a>
  <%# 줄어서 좋은 값: 색은 good, 화살표는 아래(--fall) %>
  <div class="kpi__tile">
    <span class="kpi__label">평균 검토 시간</span>
    <span class="kpi__value">1.8<span class="kpi__unit">일</span></span>
    <span class="kpi__foot"><span class="kpi__delta kpi__delta--good kpi__delta--fall"><%= icon "arrow-up" %>−0.3일</span><span class="kpi__sub">줄었어요</span></span>
  </div>
  <%# 나쁜 값 kpi__value--bad · 변화 없음 kpi__delta--flat · 4칸이면 kpi--4 %>
</section>

통계 한 줄

숫자가 주인공인 소개·요약 자리에 써요. 설명은 숫자 아래 한 줄로 둬요.

1,284이번 달 기록
98.2%기한 안에 서명
36진행 중인 과제
14참여 연구원
HTML
<div class="stat">
  <span class="stat__value num">1,284</span>
  <span class="stat__label">이번 달 기록</span>
</div>

진행 고리

작은 자리에서 얼마나 끝났는지 보여 줄 때 써요. 고리 가운데나 옆에 늘 숫자를 둬요.

항체 개발
세포주 안정화
분석법 검증
이번 달 서명80건 중 42건
누르면 dsProgress 로 고리를 채워요
HTML
<!-- app.js 가 data-value 로 채우고 role="progressbar"·aria-valuenow 를 붙인다. 크기는 --size -->
<span class="ring-wrap">
  <span class="ring" data-value="72" style="--size:64px" aria-label="항체 개발 진행률"></span>
  <span class="ring__num" aria-hidden="true">72%</span>
</span>

<script>
  // 값을 바꿀 때
  dsProgress(document.querySelector('.ring'), 54)
</script>

표

표 카드

여러 노트를 나란히 비교하고 골라서 처리할 때 써요. 머리를 누르면 정렬되고, 행을 누르면 오른쪽에 자세한 내용이 열려요. 768px 보다 좁으면 행마다 카드가 돼요.

연구노트 목록

행을 누르면 자세한 내용이 열려요

연구노트 목록. 머리의 정렬 버튼으로 순서를 바꿀 수 있어요.
과제 작성자 상태 메모 동작
PCR 증폭 조건 최적화 3차NB-2409-031
항체 개발 김지수항체팀 4개 검토 대기 어닐링 온도 58℃에서 밴드가 가장 선명했어요
웨스턴 블롯 항체 희석 비교NB-2409-030
항체 개발 정하은항체팀 6개 검토 대기 1:1000과 1:2000을 나란히 올렸어요
세포 배양 7일차 관찰NB-2409-028
세포주 안정화 최민준세포치료팀 12개 서명 완료 밀도가 80%에 가까워 내일 계대해요
HPLC 분석 조건 변경에 따른 불순물 피크 분리능 비교와 컬럼 온도 재검증NB-2409-025
분석법 검증 이서연분석팀 7개 검토 중 컬럼 온도를 30℃에서 35℃로 올려 다시 재요. 이동상 조성은 그대로 두었어요
시약 로트 변경 기록NB-2409-022
항체 개발 김지수항체팀 2개 초안 -
qPCR 표준 곡선 재작성NB-2409-019
분석법 검증 이서연분석팀 5개 검토 대기 5개 농도로 다시 그렸고 R² 0.998이에요
원심분리기 2호 교정NB-2409-015
장비 교정 한유나공용장비실 3개 서명 완료 교정 성적서를 첨부했어요
동결 세포 해동 기록NB-2409-011
세포주 안정화 최민준세포치료팀 1개 서명 완료 해동 직후 생존율 91%
128건 중 1~8

예시라서 페이지를 옮겨도 행은 그대로예요. 필터 줄에는 제출 버튼이 없고 치거나 고르는 순간 목록이 바뀌어요.

HTML
<section class="card card--line table-card" aria-labelledby="notes-title">
  <div class="table-card__head">
    <div class="card-head__text"><h2 class="card-head__title" id="notes-title">연구노트 목록</h2><p class="card-head__sub">…</p></div>
    <div class="segment segment--sm" role="group" aria-label="상태로 거르기">
      <button class="segment__item" type="button" data-value="all" aria-pressed="true">전체 <span class="num ink-muted">8</span></button> …
    </div>
  </div>
  <div class="filters" role="search" aria-label="노트 거르기">검색 · .picker--sm · 필터 지우기 · <span class="filters__count" aria-live="polite">8건</span></div>

  <div class="table-scroll">
    <table class="table table-dense table-dense--cards" id="notes-table" data-table>
      <thead><tr>
        <th class="td-check"><label class="choice"><input type="checkbox" data-select-all aria-label="보이는 노트 모두 고르기"></label></th>
        <th aria-sort="descending"><button class="table__sort" type="button">수정</button></th>
        <th class="td-num" aria-sort="none"><button class="table__sort" type="button">첨부</button></th> …
      </tr></thead>
      <tbody>
        <tr data-row data-row-open="note-sheet">
          <td class="td-check"><label class="choice"><input type="checkbox" data-select-row aria-label="NB-2409-031 고르기"></label></td>
          <td class="td-primary"><div class="cell-stack"><span class="cell-stack__title">PCR 증폭 조건 최적화 3차</span><span class="cell-stack__sub mono">NB-2409-031</span></div></td>
          <td class="td-nowrap" data-label="수정"><time datetime="2026-09-28T14:20">9/28(월) 14:20</time></td>
          <td class="td-num" data-label="첨부">4개</td>
          <td data-label="상태"><%= ui_badge "검토 대기", "amber" %></td>
          <td class="td-clip td-muted" data-label="메모" title="…">…</td>
          <td class="td-actions"><button class="btn btn--soft btn--xs" type="button">검토</button></td>
        </tr>
      </tbody>
    </table>
  </div>
  <div class="empty" hidden>…'필터 지우기'</div>
  <div class="table-card__foot"><span class="t-caption ink-muted num">128건 중 1~8</span><nav class="pager" aria-label="페이지">…</nav></div>
</section>

<!-- 하나라도 고르면 dashboard.js 가 보인다 -->
<div class="bulkbar" data-bulkbar="notes-table" role="region" aria-label="고른 노트 동작" hidden>
  <span class="bulkbar__count" data-bulk-count aria-live="polite">0건 선택</span>
  <button class="btn btn--primary btn--sm" type="button">서명 요청</button>
  <button class="btn btn--ghost btn--sm btn--square" type="button" data-bulk-clear aria-label="선택 해제">…</button>
</div>

<!-- 행을 누르거나 J·K 로 옮기고 Enter: table:open 이벤트 뒤 data-row-open 의 시트(.sheet--side)를 연다. X 는 체크 -->

옆으로 미는 표

칸을 줄일 수 없는 측정값 표는 좁은 화면에서 표 안에서만 옆으로 밀어요. 가장자리가 흐려지면 더 있다는 뜻이에요.

시약 로트별 측정값
로트 번호시약입고일농도 (mg/mL)순도 (%)pH보관 위치담당
LOT-2409-17Taq 중합효소9/21(월)5.0099.17.4냉동고 B-2김지수
LOT-2409-12dNTP 혼합액9/14(월)10.098.77.0냉동고 B-2김지수
LOT-2408-30소 태아 혈청8/31(월)42.5-7.2냉장고 A-1최민준
LOT-2408-22트립신 용액8/24(월)2.5099.57.6냉장고 A-1최민준
HTML
<!-- 칸을 줄이지 않는 표: 넘치면 표 안에서만 옆으로 밀리고, ds.js 가 넘친 쪽 가장자리를 흐리게 한다 -->
<div class="table-scroll" tabindex="0" role="region" aria-label="시약 로트별 측정값, 옆으로 밀어 보세요">
  <table class="table">
    <thead><tr><th scope="col">로트 번호</th><th scope="col">시약</th><th class="num" scope="col">농도(mg/mL)</th> …</tr></thead>
    <tbody>
      <tr><td class="mono">LOT-2409-17</td><td>Taq 중합효소</td><td class="num">5.00</td> …</tr>
    </tbody>
  </table>
</div>

빈 표

머리는 남기고 본문 한 칸에 무엇이 올지와 첫 행동을 적어요.

흡광도 측정값
측정 시각시료흡광도담당

아직 적은 측정값이 없어요

측정을 마치면 시료마다 한 줄씩 여기에 모여요.

측정값 적기
ERB
<table class="table">
  <thead><tr><th scope="col">측정 시각</th><th scope="col">시료</th><th class="num" scope="col">흡광도</th><th scope="col">담당</th></tr></thead>
  <tbody>
    <tr class="table__empty">
      <td colspan="4">
        <p class="t-body-sm w-600 ink">아직 적은 측정값이 없어요</p>
        <p class="t-caption ink-muted">측정을 마치면 시료마다 한 줄씩 여기에 모여요.</p>
        <%= ui_button "측정값 적기", size: "sm", icon_name: "plus", href: new_measurement_path %>
      </td>
    </tr>
  </tbody>
</table>

<%# 표 카드처럼 거른 결과가 비면 표를 숨기고 ui_empty 를 보인다 %>
<%= ui_empty(title: "조건에 맞는 노트가 없어요", desc: "검색어를 줄이거나 과제 조건을 지워 보세요.", icon_name: "search") { ui_button "필터 지우기" } %>

기록

키·값

실험 조건처럼 이름과 값이 짝인 정보를 보여 줄 때 써요. 값이 길면 줄을 바꾸고, 합계는 굵게 한 줄로 둬요.

실험 정보

NB-2409-031 · 기본(dt·dd 번갈아)

과제
항체 개발
실험일
9/28(월) 09:30~11:40
담당
김지수 연구원항체팀 · 한빛바이오 연구소
장비
qPCR 기기 2호공용장비실 B · 9/1 교정
시약 로트
LOT-2409-17
반응 조건
95℃ 30초, 58℃ 30초, 72℃ 45초를 35회 반복

첨부 요약

kv--rows kv--end · 쌍마다 선, 값 오른쪽

이미지
3개
측정값 표
1개
설명
3번 레인은 노출 시간을 두 배로 늘려 다시 찍었어요.
합계 용량
24.6MB
HTML
<!-- 기본: dt·dd 를 번갈아 둔다. 덧말은 .kv__sub -->
<dl class="kv">
  <dt>실험일</dt><dd>9/28(월) 09:30~11:40</dd>
  <dt>담당</dt><dd>김지수 연구원<span class="kv__sub">항체팀 · 한빛바이오 연구소</span></dd>
</dl>

<!-- 쌍마다 선 · 값 오른쪽 · 긴 글은 __row--stack · 합계는 __row--total -->
<dl class="kv kv--rows kv--end">
  <div class="kv__row"><dt>이미지</dt><dd>3개</dd></div>
  <div class="kv__row kv__row--stack"><dt>설명</dt><dd>3번 레인은 노출 시간을 두 배로 늘려 다시 찍었어요.</dd></div>
  <div class="kv__row kv__row--total"><dt>합계 용량</dt><dd>24.6MB</dd></div>
</dl>

막대 줄

몇 가지 항목의 비율을 비교할 때 써요. 이름 · 막대 · 값 세 칸이고, 막대 옆에 늘 숫자를 둬요.

과제별 기록 비율

이번 달 기록 312건 기준

  • 항체 개발 42%
  • 세포주 안정화 27%
  • 분석법 검증 18%
  • 장비 교정·유지 보수 기록 9%
  • 기타 4%

많이 적은 과제부터 보여요. 긴 이름은 말줄임하고 전체 이름은 title 로 둬요.

공용 장비 사용률

오늘 업무 시간 9시간 중

  • qPCR 기기 2호 92%
  • HPLC 1호 74%
  • 클린벤치 A 68%
  • 원심분리기 2호 41%

90%를 넘은 장비는 주황 막대로 보여요. 색만으로 알리지 않게 숫자를 늘 함께 둬요.

HTML
<ul class="bar-rows">
  <li class="bar-row">
    <span class="bar-row__label" title="항체 개발">항체 개발</span>
    <span class="meter" aria-hidden="true"><span class="meter__fill" style="--p:42"></span></span>
    <span class="bar-row__value">42%</span>
  </li>
  …
</ul>
<!-- 넘은 값은 meter__fill--warn · --bad · --good. 이름 칸을 넓히려면 bar-row--wide -->

타임라인

한 노트가 어떤 순서로 바뀌었는지 보여 줄 때 써요. 상태는 표시 모양과 숨은 글자로 함께 알려요.

노트 변경 이력

NB-2409-031 · PCR 증폭 조건 최적화 3차

  1. 노트를 만들었어요 · 끝남

    김지수 연구원 · PCR 기본 양식

  2. 측정값 표를 고쳤어요 · 끝남

    3행 농도 1.20 → 1.25 mg/mL

  3. 검토를 요청했어요 · 끝남

    박도윤 책임연구원에게

  4. 검토하고 있어요 · 진행 중

    보통 하루 안에 끝나요

  5. 첨부 파일을 열지 못했어요 · 문제

    gel_0924.tif 파일이 손상됐어요. 다시 올려 주세요.

  6. 외부 기관 검토 · 건너뜀

    이 과제는 외부 검토가 없어 건너뛰었어요

  7. 전자서명 · 아직

HTML
<ol class="timeline">
  <li class="timeline__item" data-state="done">
    <span class="timeline__mark" aria-hidden="true"></span>
    <div class="timeline__body">
      <div class="timeline__head">
        <p class="timeline__title">검토를 요청했어요<span class="sr-only"> · 끝남</span></p>
        <time class="timeline__time" datetime="2026-09-28T10:02">9/28 10:02</time>
      </div>
      <p class="timeline__desc">박도윤 책임연구원에게</p>
    </div>
  </li>
  <%# data-state: done 체크 · now 퍼지는 고리 · bad 느낌표 · skip 그은 제목 · todo 빈 원. 촘촘하게는 timeline--dense %>
</ol>

대화 말풍선

검토 댓글처럼 주고받는 말을 시간순으로 보여 줄 때 써요. 새 말풍선은 dsChatAdd 로 붙이고, 끝을 보고 있으면 따라 내려가요.

9월 28일 월요일

박도윤 책임연구원

3번 레인 밴드가 흐려요. 프라이머 농도를 적어 주실래요?

14:10

0.4μM으로 넣었어요

표 3행에 적어 둘게요

14:12
이서연 선임연구원이 검토에 들어왔어요

이서연 선임연구원

고친 표 확인했어요. 이대로 서명할까요?

14:20

사진도 다시 올렸어요

보내지 못했어요
HTML · JS
<div class="chat" role="log" aria-label="검토 댓글 기록">
  <p class="chat__day">9월 28일 월요일</p>
  <p class="chat__who">박도윤 책임연구원</p>
  <div class="bubble bubble--ai"><p class="bubble__body">3번 레인 밴드가 흐려요. 프라이머 농도를 적어 주실래요?</p><span class="bubble__time">14:10</span></div>
  <div class="bubble bubble--me"><p class="bubble__body">0.4μM으로 넣었어요</p></div>
  <div class="bubble bubble--sys">이서연 선임연구원이 검토에 들어왔어요</div>
  <div class="bubble bubble--me bubble--failed"><p class="bubble__body">사진도 다시 올렸어요</p>
    <span class="bubble__status">보내지 못했어요 <button class="bubble__retry" type="button">다시 보내기</button></span></div>
</div>
<!-- 입력 바는 코어 .dock(.dock--static 은 카드 안에 둘 때) -->

<script>
  // 한 줄 붙이기: 붙인 말풍선은 보낸 쪽에서 부풀며 올라오고, 끝을 보고 있으면 따라 내려간다
  dsChatAdd(chat, { from: 'me', text: '표 3행을 고쳤어요', time: '14:30' })
  const typing = dsChatAdd(chat, { from: 'ai', typing: true })   // 입력 중 점
  typing.remove(); dsChatAdd(chat, { from: 'ai', text: '확인했어요', time: '14:31' })
</script>

차트·달력

막대 차트

요일·종류처럼 나뉜 값을 비교할 때 써요. 제목에 결론을 쓰고, 화면 읽기용 aria-label 에 값을 모두 적어요.

목요일에 가장 많이 적었어요

9/21(월)~9/27(일) · 하루 기록 수

HTML
<!-- 라이브러리 없이 app.js 가 SVG 로 그린다. 제목에 결론, aria-label 에 값 전부 -->
<svg class="chart" data-chart="bar"
     data-values="18,24,21,30,26,5,4" data-labels="월,화,수,목,금,토,일"
     data-emphasis="max" data-ticks="int"
     role="img" aria-label="이번 주 요일별 기록. 목요일이 30건으로 가장 많아요. 월 18건, 화 24건, …"></svg>

<!-- 꺾은선: 이름이 겹치면 빈 칸으로 건너뛴다 -->
<svg class="chart" data-chart="line"
     data-values="64,71,69,83,90,96,114,128" data-labels="8/3,,8/17,,8/31,,9/14,"
     role="img" aria-label="지난 8주 주간 기록이 64건에서 128건으로 두 배가 됐어요. …"></svg>

<!-- data-emphasis: last(기본, 시계열) · max(가장 큰 값) · none(모두 같은 색). 작은 칸은 chart--bare -->

꺾은선 차트

시간에 따라 늘고 준 흐름을 볼 때 써요. 눈금 글자가 겹치지 않게 이름은 한 칸씩 건너 적어요.

8주 만에 기록이 두 배가 됐어요

8/3~9/27 · 주마다 기록 수

HTML
<!-- 라이브러리 없이 app.js 가 SVG 로 그린다. 제목에 결론, aria-label 에 값 전부 -->
<svg class="chart" data-chart="bar"
     data-values="18,24,21,30,26,5,4" data-labels="월,화,수,목,금,토,일"
     data-emphasis="max" data-ticks="int"
     role="img" aria-label="이번 주 요일별 기록. 목요일이 30건으로 가장 많아요. 월 18건, 화 24건, …"></svg>

<!-- 꺾은선: 이름이 겹치면 빈 칸으로 건너뛴다 -->
<svg class="chart" data-chart="line"
     data-values="64,71,69,83,90,96,114,128" data-labels="8/3,,8/17,,8/31,,9/14,"
     role="img" aria-label="지난 8주 주간 기록이 64건에서 128건으로 두 배가 됐어요. …"></svg>

<!-- data-emphasis: last(기본, 시계열) · max(가장 큰 값) · none(모두 같은 색). 작은 칸은 chart--bare -->

달력 보기

날짜별로 쌓인 기록을 훑을 때 써요. 기록이 있는 날에 점이 찍히고, 날짜를 누르면 그날 노트가 함께 보여요. 화살표 키로 날짜를 옮겨요.

HTML
<!-- 기록이 있는 날은 data-marks 에 점. 앞날은 data-max 로 막는다. 고르면 cal:change(detail.date) -->
<div class="cal" id="note-cal" data-cal
     data-today="2026-09-28" data-value="2026-09-28" data-max="2026-09-28"
     data-marks="2026-09-22,2026-09-24,2026-09-25,2026-09-28"></div>

<script>
  document.getElementById('note-cal').addEventListener('cal:change', (e) => {
    // e.detail.date 의 노트를 옆 목록에 그린다(서버라면 Turbo Frame 을 다시 불러온다)
  })
</script>

사진·후기

사진 카드

소개 화면에서 사진 위에 제목을 얹고, 눌러서 설명을 펼칠 때 써요. 여기서는 사진 자리를 그라데이션 면으로 그렸어요.

HTML
<!-- ds.js 가 누를 때마다 aria-expanded 를 바꾸고 설명을 펼친다 -->
<button class="photocard" type="button">
  <img class="photocard__img" src="…" alt="">
  <span class="photocard__inner">
    <span class="photocard__title">세포 배양실은 매일 같은 시간에 기록해요</span>
    <span class="photocard__plus" aria-hidden="true"><%= icon "plus", size: "sm" %></span>
    <span class="photocard__more"><span>배양 7일차까지 같은 배율로 찍은 사진을 노트에 붙여 두면 밀도 변화가 한눈에 보여요.</span></span>
  </span>
</button>

인용 캐러셀

후기를 한 번에 하나씩 넘겨 볼 때 써요. 좁은 화면에서는 화살표 대신 점과 밀기로 넘기고, 좌우 화살표 키로도 넘어가요.

예시 후기 · 실제 연구원의 말이 아니에요

실험하면서 바로 적으니 퇴근 전에 노트를 다시 옮겨 적을 일이 없어졌어요.

김지수 연구원 · 한빛바이오 연구소

검토 댓글이 노트 옆에 남아서 무엇을 고쳤는지 다시 묻지 않아요.

박도윤 책임연구원 · 세포치료 연구팀

감사 기간에도 변경 이력을 따로 정리하지 않아도 돼서 한 주가 가벼워졌어요.

이서연 선임연구원 · 분석법 개발팀

HTML
<div class="quotes" data-quotes tabindex="0" aria-roledescription="캐러셀" aria-label="연구원 후기">
  <button class="quotes__nav quotes__nav--prev" type="button" data-quotes-prev aria-label="이전 후기">…</button>
  <button class="quotes__nav quotes__nav--next" type="button" data-quotes-next aria-label="다음 후기">…</button>
  <div class="quotes__track"><div class="quotes__rail">
    <div class="quotes__item">
      <img class="quotes__face" src="…" alt="">
      <p class="quotes__text">…</p>
      <p class="quotes__by">김지수 연구원 · 한빛바이오 연구소</p>
    </div>
    …
  </div></div>
  <div class="quotes__dots" role="tablist" aria-label="후기 고르기"></div>   <!-- 점은 ds.js 가 만든다 -->
</div>
<!-- 스스로 넘기려면 data-quotes-auto="6000"(모션 축소면 멈춘다). 후기는 근거가 있는 것만 싣는다 -->

12상태

기다릴 때도, 비었을 때도 할 일을 알려요

모든 화면은 불러오는 중·비어 있음·실패·끝남 네 상태를 가져요. 원인과 가장 가까운 자리에서, 색만이 아니라 아이콘과 글자로 함께 알려요.

알림 배너

화면이나 영역 위에 오래 남아야 할 한 가지를 알려요. 금방 사라져도 되면 토스트, 흐름을 멈춰야 하면 확인 창이에요.

info · 알아 둘 것, 제목과 동작 링크
검토가 끝나야 서명할 수 있어요 박도윤 책임연구원이 검토하고 있어요. 보통 하루 안에 끝나요. 검토 현황 보기
warn · 지금 챙길 것
good · 끝난 일
검토를 마쳤어요. 이제 이 노트에 서명할 수 있어요.
bad · 원인과 할 일
plain · 덜 급한 안내
매주 월요일 오전 9시에 지난주 노트 요약을 보내요.

warn·bad 에는 role="alert" 가 저절로 붙어 화면 읽기가 바로 읽어요. 색만으로 구분하지 않게 아이콘이 함께 바뀌어요.

ERB
<%# 제목 + 본문 + 동작 링크. tone: info · warn · good · bad · plain (warn·bad 는 role="alert" 가 저절로 붙는다) %>
<%= ui_callout(tone: "info", title: "검토가 끝나야 서명할 수 있어요") do %>
  박도윤 책임연구원이 검토하고 있어요. 보통 하루 안에 끝나요.
  <%= link_to "검토 현황 보기", reviews_path %>
<% end %>

<%# 한 줄이면 글자만 %>
<%= ui_callout "검토를 마쳤어요. 이제 이 노트에 서명할 수 있어요.", tone: "good" %>

<%# 아이콘을 바꿀 때 %>
<%= ui_callout "매주 월요일 오전 9시에 지난주 노트 요약을 보내요.", tone: "plain", icon_name: "clock" %>

진행 막대

얼마나 남았는지 아는 일(업로드·여러 단계 입력)에 써요. dsProgress 가 막대 폭과 화면 읽기 값을 함께 맞춰요.

PCR 결과 이미지 올리기 40%
끝을 모를 때올릴 준비를 하고 있어요
멈췄을 때62%에서 멈췄어요
단계 입력(ui_steps)실험 조건 적기
ERB · JS
<%# 정해진 값: data-value 로 그리면 app.js 가 폭과 role="progressbar"·aria-valuenow 를 맞춘다 %>
<div class="steps">
  <span class="steps__bar" data-value="40" aria-label="PCR 결과 이미지 올리기"><span></span></span>
</div>

<%# 값을 바꿀 때는 dsProgress 하나로(폭 + 화면 읽기 값) %>
dsProgress(document.querySelector('.steps__bar'), 60)

<%# 끝을 모를 때 · 멈췄을 때 · 끝났을 때 %>
<span class="steps__bar" data-indeterminate aria-label="올릴 준비를 하고 있어요"><span></span></span>
<span class="steps__bar" data-value="62" data-state="bad" aria-label="올리기가 62%에서 멈췄어요"><span></span></span>
<span class="steps__bar" data-value="100" data-state="good" aria-label="모두 올렸어요"><span></span></span>

<%# 여러 단계 입력: 현재 단계 / 전체 %>
<%= ui_steps(2, 4, label: "노트 작성 단계") %>

진행 고리

목록 행 끝·카드 모서리처럼 좁은 자리에 남은 양을 보여요. 가운데가 뚫려 있어 어떤 면에 얹어도 바탕과 어긋나지 않아요.

시작 전
저장 공간
모두 끝남
첨부 파일 3개 올리기 전이에요

고리 크기와 굵기는 CSS 변수 --size·--ring-w 로만 바꿔요. 숫자는 고리 가운데 ring__num 에 두고 화면 읽기에는 숨겨요.

ERB · JS
<%# 가운데가 뚫린 고리. 크기·굵기는 CSS 변수로만(--size 기본 52px, --ring-w 기본 4px) %>
<span class="ring-wrap">
  <span class="ring" data-value="53" aria-label="저장 공간"></span>
  <span class="ring__num" aria-hidden="true">53</span>
</span>

<span class="ring" data-value="0" style="--size:40px" aria-label="첨부 파일 올리기"></span>

<%# 값 바꾸기(--p 와 aria-valuenow 를 함께) %>
dsProgress(document.querySelector('.ring'), 75)

스켈레톤

모양을 아는 내용이 늦게 올 때 그 자리를 같은 크기로 미리 잡아 화면이 밀리지 않게 해요. 목록은 3~5행만 그려요.

목록 행 · skel-row
노트 목록을 불러오고 있어요
카드 · skel--block
장비 사용 현황을 불러오고 있어요
글 줄 · skel--title + skel--line
노트 본문을 불러오고 있어요

불러오는 구역에 aria-busy="true", 스켈레톤 자체는 aria-hidden. 빛이 지나가는 시머는 모션 줄이기 설정에서 멈춰요. 반투명 틴트라 흰 카드·다크 면 어디서나 보여요.

ERB
<%# 불러오는 구역에 aria-busy, 스켈레톤 자체는 aria-hidden. 실제 내용과 같은 크기로 3~5행만 %>
<div aria-busy="true">
  <span class="sr-only">노트 목록을 불러오고 있어요</span>
  <div aria-hidden="true">
    <div class="skel-row">
      <span class="skel skel-row__lead"></span>
      <span class="skel-row__main"><span class="skel skel--line"></span><span class="skel skel--line"></span></span>
    </div>
  </div>
</div>

<%# 카드 · 글 줄 %>
<div class="skel skel--block"></div>
<div class="skel skel--title"></div>
<div class="skel skel--line"></div>

<%# 원(아바타 자리) %>
<span class="skel skel--circle"></span>

<%# 시머는 prefers-reduced-motion: reduce 에서 멈춘다(components.css 가 한다) %>

처리 중 버튼

버튼으로 시작한 일은 그 버튼에서 기다려요. 누르는 즉시 두 번 눌림을 막고, 300ms 가 지나도 안 끝났을 때만 스피너를 보여요.

두 버튼을 눌러 보세요. 180ms 만에 끝나는 쪽은 스피너가 뜨지 않아요.

걸리는 시간보여 줄 것
300ms 미만아무것도 띄우지 않아요
300ms~1초스켈레톤이나 버튼 스피너
1~10초스켈레톤을 유지하고 필요하면 '불러오고 있어요' 한 줄
10초 넘음하는 일·걸리는 시간·취소 방법
30초 넘음뒤에서 계속하고 끝나면 알려요
ERB · JS
<%# 처음부터 처리 중으로 그릴 때 %>
<%= ui_button "보내기", variant: "primary", busy: true %>

// 누르는 즉시 두 번 눌림을 막고, 300ms 가 지나도 안 끝났을 때만 스피너를 보인다(docs/11)
// disabled 는 버튼을 흐리게 만들어 깜빡이므로 쓰지 않는다
async function withBusy(btn, work) {
  if (btn.dataset.pending) return
  btn.dataset.pending = "1"
  const t = setTimeout(() => btn.setAttribute("data-busy", ""), 300)
  try { return await work() }
  finally { clearTimeout(t); btn.removeAttribute("data-busy"); delete btn.dataset.pending }
}

<%# Turbo 로 보내는 폼은 할 일이 없다. 보내는 동안 form[aria-busy] 의 제출 버튼이 같은 모양이 된다 %>

로딩은 300ms 부터

빨리 오면 아무것도 띄우지 않아요. 깜빡임이 기다림보다 거슬려요. 한 번 뜬 스켈레톤은 400ms 는 머물러요.

PCR 조건 최적화 3차김지수 연구원 · 검토 중
세포 배양 기록 12일차이서연 연구원 · 서명 완료
시약 재고 점검 9월정하준 연구원 · 작성 중

마지막으로 불러온 때 9/28(월) 18:00

'느린 응답'을 누르면 300ms 뒤에만 스켈레톤이 떠요.

JS · ERB
// 300ms 안에 오면 아무것도 띄우지 않고, 넘으면 스켈레톤. 한 번 떴으면 400ms 는 유지한다.
async function load(box, fetchList) {
  box.setAttribute("aria-busy", "true")
  let shownAt = 0
  const show = setTimeout(() => { shownAt = performance.now(); box.dataset.loading = "" }, 300)
  const html = await fetchList()
  clearTimeout(show)
  if (shownAt) {
    const held = performance.now() - shownAt
    if (held < 400) await new Promise(r => setTimeout(r, 400 - held))
  }
  box.innerHTML = html
  delete box.dataset.loading
  box.removeAttribute("aria-busy")
}

<%# Turbo Frame 은 loading="lazy" 로 늦게 오는 부분만 스켈레톤을 둔다 %>
<%= turbo_frame_tag "notes", src: notes_path, loading: "lazy" do %>
  <div aria-hidden="true"><div class="skel-row">…</div></div>
<% end %>

빈 상태 3가지

여기에 무엇이 올지와 첫 행동 하나를 알려요. 찾은 결과가 없으면 검색어를 되풀이하고 고칠 방법을 줘요.

처음 쓸 때

아직 기록한 노트가 없어요

첫 노트를 쓰면 과제별로 여기에 모여요. 예: 9/28 PCR 조건 확인

노트 쓰기
찾은 결과가 없을 때 · 칸에 적어 보세요

'PCR 버퍼' 로 찾은 노트가 없어요

철자를 확인하거나 더 짧은 말로 찾아 보세요.

볼 권한이 없을 때

책임연구원만 볼 수 있어요

이 과제의 서명 기록은 박도윤 책임연구원이 관리해요. 요청하면 승인 뒤에 볼 수 있어요.

ERB
<%# 처음: '아직 ~ 없어요' + 첫 행동 하나 %>
<%= ui_empty(title: "아직 기록한 노트가 없어요", desc: "첫 노트를 쓰면 과제별로 여기에 모여요. 예: 9/28 PCR 조건 확인", icon_name: "doc") do %>
  <%= ui_button "노트 쓰기", variant: "primary", href: new_note_path, icon_name: "plus" %>
<% end %>

<%# 찾은 결과 없음: 검색어를 되풀이하고 고칠 방법 %>
<%= ui_empty(title: "'#{params[:q]}' 로 찾은 노트가 없어요", desc: "철자를 확인하거나 더 짧은 말로 찾아 보세요.", icon_name: "search") do %>
  <%= ui_button "검색어 지우기", href: notes_path %>
<% end %>

<%# 권한 없음: 누가 볼 수 있는지 + 요청하는 길 %>
<%= ui_empty(title: "책임연구원만 볼 수 있어요", desc: "요청하면 승인 뒤에 볼 수 있어요.", icon_name: "lock") do %>
  <%= ui_button "열람 요청하기", href: new_access_request_path %>
<% end %>

불러오지 못한 영역

실패한 영역만 멈추고 나머지 화면은 살려 둬요. 원인과 할 일을 쓰고 그 자리에 다시 불러오기 버튼을 둬요.

같은 오류가 세 번 넘게 나면 '문의하기' 같은 다른 길을 함께 줘요. 서버 오류 번호·영어 메시지는 보이지 않아요.

ERB · JS
<%# 영역 하나가 실패하면 그 자리에 원인 + 할 일 + 다시 불러오기. 나머지 화면은 그대로 둔다 %>
<%= ui_callout(tone: "bad", title: "첨부 파일 목록을 불러오지 못했어요") do %>
  인터넷 연결이 잠깐 끊겼어요. 노트 본문은 그대로 있어요.
  <%= ui_button "다시 불러오기", size: "sm", icon_name: "refresh", data: { action: "attachments#reload" } %>
<% end %>

<%# 버튼 동작이 실패하면 버튼은 원래대로, 알림은 토스트로 %>
dsToast("저장하지 못했어요. 인터넷 연결을 확인해 주세요", { urgent: true, tone: "bad", action: "다시 시도", onAction: save })

연결이 끊겼을 때

화면 맨 위 띠로 알리고 적은 내용은 지켜요. 다시 연결되면 띠를 걷고 토스트로 알려요. 실제로 인터넷을 끊어도 같이 바뀌어요.

인터넷 연결이 끊겼어요. 적은 내용은 연결되면 보낼게요

세포 배양 기록 12일차고친 내용 2곳 · 연결되면 저장해요
PCR 조건 최적화 3차마지막으로 불러온 때 9/28(월) 18:00
ERB · JS
<%# 화면 맨 위 띠(.announce). 닫기 버튼 없이 연결되면 걷는다 %>
<div class="announce announce--warn" role="status" id="offline-band" hidden>
  <div class="container announce__inner">
    <p class="announce__text">인터넷 연결이 끊겼어요. 적은 내용은 연결되면 보낼게요</p>
  </div>
</div>

// navigator.onLine 은 참고만 한다. 실패한 요청이 기준이고 이벤트는 보조다
addEventListener("offline", () => { band.hidden = false })
addEventListener("online", () => { band.hidden = true; dsToast("다시 연결됐어요", { tone: "good" }) })

결과 화면

사용자가 결과를 기다린 일(검토 요청·서명·제출)의 끝을 한 화면으로 알려요. 그림이 먼저 그려지고 제목·설명·요약·버튼이 차례로 나와요.

검토 요청을 보냈어요

박도윤 책임연구원에게 알림을 보냈어요. 검토가 끝나면 서명할 수 있어요.

노트
PCR 조건 최적화 3차
검토자
박도윤 책임연구원
요청 번호
RV-2609-0142
노트로 돌아가기
상태를 바꿔 보세요 · dsResult
그림 4가지 · check-anim
완료 --good
실패 --bad
기다림 --wait
브랜드 색 --accent

가벼운 일(설정 저장·복사)은 토스트로 끝내요. 결과 화면을 남발하면 흐름이 끊겨요. 모션 줄이기에서는 그림이 완성된 모습으로 바로 보여요.

ERB · JS
<section class="result result--fill" aria-live="polite">
  <div class="result__art">
    <svg class="check-anim check-anim--good" viewBox="0 0 80 80" aria-hidden="true"></svg>  <%# 비어 있으면 extras.js 가 그림을 채운다 %>
  </div>
  <h1 class="result__title">검토 요청을 보냈어요</h1>
  <p class="result__desc">박도윤 책임연구원에게 알림을 보냈어요. 검토가 끝나면 서명할 수 있어요.</p>
  <dl class="result__meta">
    <div><dt>노트</dt><dd>PCR 조건 최적화 3차</dd></div>
    <div><dt>요청 번호</dt><dd>RV-2609-0142</dd></div>
  </dl>
  <div class="result__actions">
    <%= ui_button "요청 번호 복사", variant: "ghost", size: "sm", data: { copy: "RV-2609-0142" } %>
  </div>
</section>
<%# 앱 화면의 주 동작은 하단 고정 바에 둔다 %>
<% app_actionbar do %><%= ui_button "노트로 돌아가기", variant: "primary", size: "lg", href: notes_path %><% end %>

// 상태를 바꾸고 그림·글의 등장을 처음부터 다시 돌린다(good · bad · wait). ds:result 이벤트
dsResult(el, "wait", { title: "검토를 기다리고 있어요", desc: "박도윤 책임연구원이 확인하면 이 화면이 바뀌어요." })

축하

첫 서명·가입 완료처럼 큰 첫 성공에 한 번만 써요. 반복 작업·삭제·결제에는 쓰지 않아요.

첫 연구노트에 서명했어요

조각이 버튼 가운데에서 퍼져요. 모션 줄이기면 띄우지 않아요.

JS
// 큰 첫 성공(첫 서명·가입 완료)에만 한 번. el 가운데(없으면 화면 위쪽 가운데)에서 조각 26개가 1초 안에 퍼졌다 사라진다
// 모션 줄이기면 아무것도 하지 않는다. 끝나면 DOM 을 걷고 약속을 돌려준다
await dsCelebrate(button)

// 결과 화면과 함께: 그림이 그려진 뒤(약 560ms) 띄우면 겹치지 않는다
dsResult(el, "good", texts)
setTimeout(() => dsCelebrate(el.querySelector(".check-anim")), 560)

진동

휴대폰에서 완료·오류를 손끝으로도 알려요. 사용자가 누른 동작 안에서만 울리고, 소리는 내지 않아요.

안드로이드는 진동 무늬로, 아이폰 사파리는 스위치 진동을 한 번 빌려 울려요. 데스크톱에서는 아무 일도 없어요.

ERB · JS
<%# 누르면 저절로 부른다(extras.js) %>
<button class="btn btn--primary" type="button" data-haptic="success">서명하기</button>

// 직접 부를 때: 'light' | 'success' | 'error' | 'select'. 진동을 냈으면 true
// 안드로이드는 navigator.vibrate 무늬, 아이폰 사파리는 스위치 진동을 한 번 빌린다. 사용자 동작 밖에서는 조용히 넘어간다
const ok = dsHaptic("error")

<%# 서비스 설정에서 끄려면 %>
<html data-haptics="off">

권한은 필요한 순간에 하나씩

브라우저 권한 창은 한 번 거절하면 다시 띄울 수 없어요. 우리 시트로 무엇을 언제 알리는지 먼저 말하고, 누를 때만 브라우저 창을 띄워요.

검토를 요청한 직후 · 예시
검토 결과 알림 꺼져 있어요

'알림 켜기'를 누르면 먼저 우리 안내 시트가 떠요. 시트에서 한 번 더 누르면 이 브라우저의 실제 권한 창이 떠요. 이 화면은 허용해도 알림을 보내지 않아요.

권한묻는 순간 · 거절하면
알림첫 검토를 요청한 직후 '결과가 오면 알려 드릴까요?'화면 안 알림으로 대신하고 설정에서 켜는 길을 한 줄 적어요
마이크음성 메모 버튼을 누른 순간글 입력으로 바로 바꾸고 적던 내용은 지켜요
카메라실험 사진 찍기를 누른 순간파일에서 고르기로 바꿔요
위치위치가 필요한 기능을 누른 순간주소를 직접 적게 해요
ERB · JS
<%# 1. 우리 시트로 먼저 설명한다(무엇을 · 언제). 여는 버튼은 알림이 쓸모 있는 순간에 둔다 %>
<%= ui_sheet "notify-sheet", title: "검토 결과를 바로 알려 드릴까요?", desc: "이 브라우저로 아래 세 가지만 알려 드려요." do |s| %>
  <% s.body do %> …무엇을 알리는지 목록… <% end %>
  <% s.foot do %>
    <%= ui_button "나중에", data: { close: "" } %>
    <%= ui_button "알림 켜기", variant: "primary", data: { action: "notify#enable" } %>
  <% end %>
<% end %>

// 2. 이미 막았는지 먼저 본다. 막혀 있으면 브라우저 창 대신 설정 안내
const { state } = await navigator.permissions.query({ name: "notifications" })
if (state === "denied") dsOpen("notify-denied")
else dsOpen("notify-sheet")

// 3. 브라우저 창은 사용자가 누른 동작 안에서만(iOS 사파리는 그렇지 않으면 뜨지 않는다)
async enable() {
  dsClose("notify-sheet")
  const result = await Notification.requestPermission()
  dsToast(result === "granted" ? "검토 결과 알림을 켰어요" : "나중에 켤 수 있어요")
}

13오버레이

띄운 창은 닫으면 제자리로 돌아와요

좁은 화면에서는 아래에서 올라오는 시트, 넓은 화면에서는 가운데 창이 돼요. 초점 가두기·Escape·배경 잠금·연 자리로 초점 돌려놓기는 키트가 해요.

시트

지금 화면을 떠나지 않고 한 가지를 정할 때 써요. 제목·설명·본문·아래 버튼 둘로 짜고, 768px 부터는 가운데 창이 돼요.

휴대폰에서는 손잡이를 아래로 90px 넘게 끌면 닫혀요. Escape·바깥 덮개·닫기 버튼으로도 닫혀요.

ERB · JS
<%# 여는 버튼 %>
<%= ui_button "검토 요청하기", variant: "primary", data: { open: "review-sheet" } %>

<%# 시트는 본문이 아니라 content_for :overlays 안에 둔다(스크롤·겹침 문제가 없다) %>
<% content_for :overlays do %>
  <%= ui_sheet "review-sheet", title: "박도윤 책임연구원에게 검토를 요청해요",
        desc: "요청하면 검토가 끝날 때까지 이 노트를 고칠 수 없어요." do |s| %>
    <% s.body do %>
      <dl class="kv"><dt>노트</dt><dd>PCR 조건 최적화 3차</dd></dl>
      <%= f.text_area :memo, label: "검토자에게 남길 말", optional: true %>
    <% end %>
    <% s.foot do %>
      <%= ui_button "닫기", data: { close: "" } %>
      <%= ui_button "검토 요청하기", variant: "primary", type: "submit" %>
    <% end %>
  <% end %>
<% end %>

// 스크립트로 열고 닫기(닫으면 연 버튼으로 초점이 돌아간다)
dsOpen("review-sheet", button)
dsClose("review-sheet")

쌓는 시트

주버튼을 크게 보여야 하거나 버튼 글이 길 때 아래 버튼을 세로로 쌓아요(stack: true). 주버튼이 위예요.

시트 안에서 '링크 복사하기'를 눌러 보세요. 시트가 열려 있으면 토스트가 화면 위쪽에 떠요.

ERB
<%# stack: true 면 아래 버튼을 세로로 쌓는다. 주버튼이 위, 보조가 아래 %>
<%= ui_sheet "share-sheet", title: "공유 링크를 만들었어요", stack: true,
      desc: "링크를 받은 한빛바이오 연구소 구성원만 읽을 수 있어요. 7일 뒤에 닫혀요." do |s| %>
  <% s.body do %>
    <%= ui_input :link, share_url, label: "공유 링크", readonly: true %>
  <% end %>
  <% s.foot do %>
    <%= ui_button "링크 복사하기", variant: "primary", size: "lg", icon_name: "copy",
          data: { copy: share_url, copy_message: "공유 링크를 복사했어요" } %>
    <%= ui_button "닫기", size: "lg", data: { close: "" } %>
  <% end %>
<% end %>

가운데 창

짧은 확인·안내에 써요(ui_modal). 640px 이하에서는 아래 시트가 되고, 버튼은 확인 창과 같은 두 칸이에요.

되돌릴 수 없는 동작의 확인은 마크업 없이 dsConfirm 이 더 간단해요.

ERB
<%# 가운데 창(640px 이하는 아래 시트). 아래 버튼은 확인 창과 같은 두 칸(confirm__actions) %>
<%= ui_modal "publish-modal", title: "이 노트를 연구실 전체에 공개할까요?",
      desc: "한빛바이오 연구소 구성원 18명이 읽을 수 있어요. 고치는 건 작성자와 검토자만 할 수 있어요." do |m| %>
  <% m.body do %>
    <%= ui_callout "공개하면 감사 기록에 공개한 사람과 시각이 남아요.", tone: "plain", icon_name: "lock" %>
  <% end %>
  <% m.foot do %>
    <%= ui_button "그대로 두기", size: "lg", data: { close: "" } %>
    <%= ui_button "공개하기", variant: "primary", size: "lg", type: "submit" %>
  <% end %>
<% end %>

오른쪽 상세 시트

목록·표에서 한 줄을 눌러 자세히 볼 때 써요(sheet--side). 768px 부터 오른쪽에서 밀려 나오는 판, 휴대폰은 아래 시트예요.

행을 눌러 보세요. '반려'는 시트 위에 확인 창을 한 번 더 띄워요.

ERB
<%# 오른쪽 상세 시트: 코어 .sheet 에 --side. 머리(배지·번호·제목 | 닫기) → .kv → .sheet__sec 반복 → 발 %>
<div class="overlay" id="note-side" hidden>
  <div class="overlay__scrim"></div>
  <div class="sheet sheet--side" role="dialog" aria-modal="true" aria-labelledby="note-side-title" tabindex="-1">
    <button class="sheet__grip" type="button" aria-label="끌어서 닫기"></button>
    <div class="sheet__head">
      <div class="stack stack--sm">
        <span><%= ui_badge "검토 중", "amber" %> <span class="t-caption ink-subtle mono">EN-2609-0142</span></span>
        <h2 class="t-heading-sm ink" id="note-side-title">PCR 조건 최적화 3차</h2>
      </div>
      <%= ui_button "닫기", variant: "ghost", square: true, icon_name: "close", data: { close: "" } %>
    </div>
    <div class="sheet__body">
      <dl class="kv"><dt>작성</dt><dd>김지수 연구원</dd><dt>검토자</dt><dd>박도윤 책임연구원</dd></dl>
      <section class="sheet__sec" aria-labelledby="note-side-log">
        <h3 class="sheet__sec-title" id="note-side-log">기록</h3>
        <ol class="timeline timeline--dense">…</ol>
      </section>
    </div>
    <div class="sheet__foot">
      <%= ui_button "반려", confirm: "김지수 연구원에게 반려 알림이 가요.", confirm_title: "이 노트를 반려할까요?",
            confirm_tone: "danger", data: { confirm_ok: "반려하기" } %>
      <%= ui_button "승인", variant: "primary" %>
    </div>
  </div>
</div>

<%# 목록 행 전체가 여는 버튼 %>
<button class="row-item" type="button" data-open="note-side">…</button>

시트 안 선택기·메뉴

시트 안에서 연 목록은 시트 위층에 떠요. 목록에서 Escape 를 누르면 목록만 닫히고 시트는 그대로예요.

좁은 화면에서는 선택기·메뉴도 아래 시트로 떠요. 네이티브 select 는 쓰지 않아요.

ERB · JS
<%# 시트 안에서도 폼 빌더 그대로. 목록은 열린 동안 시트 위층(data-layer="top")에 뜬다 %>
<%= ui_sheet "conditions-sheet", title: "실험 조건 바꾸기" do |s| %>
  <% s.body do %>
    <%= f.picker :equipment_id, equipment_choices, label: "장비" %>
    <%= f.picker :lot, lot_choices, label: "중합효소 로트", data: { picker_search: "로트 번호로 찾기" } %>

    <div data-menu data-menu-title="조건 더 보기">
      <%= ui_button "더 보기", size: "sm", icon_name: "chevron-down", icon_after: true, data: { menu_trigger: "" } %>
      <div class="menu" role="menu" data-align="end" hidden>
        <button class="menu__item" role="menuitem" type="button" data-value="copy"><%= icon("copy", class: "menu__icon") %>조건 복제하기</button>
        <div class="menu__sep" role="separator"></div>
        <button class="menu__item menu__item--danger" role="menuitem" type="button" data-value="clear"><%= icon("trash", class: "menu__icon") %>조건 모두 지우기</button>
      </div>
    </div>
  <% end %>
<% end %>

// 고르면 ds:change(선택기)·ds:select(메뉴) 이벤트. Escape 는 목록만 닫고 시트는 그대로 둔다
menuRoot.addEventListener("ds:select", (e) => e.detail.value)

시트 위에 확인 창

시트에서 되돌릴 수 없는 일을 할 때 확인 창이 시트 위에 겹쳐요. Escape 는 맨 위 하나만 닫아요.

시트에서 '닫기'를 누르면 '적은 내용을 버릴까요?'가 겹쳐 떠요. Escape 를 한 번 누르면 확인 창만, 두 번 누르면 시트까지 닫혀요.

ERB · JS
<%# 시트의 '닫기'에 data-confirm 을 달면 확인 창이 시트 위에 겹친다. 확인하면 같은 버튼을 다시 눌러 시트가 닫힌다 %>
<%= ui_button "닫기", data: { close: "", confirm: "고친 내용이 저장되지 않고 사라져요.",
      confirm_title: "적은 내용을 버릴까요?", confirm_ok: "버리기", confirm_cancel: "계속 쓰기", confirm_tone: "danger" } %>

// 시트 안에서 스크립트로 겹쳐 띄워도 같다. Escape·초점 가두기는 맨 위 창만 한다
const ok = await dsConfirm({ title: "실험 조건을 모두 지울까요?", confirm: "지우기", tone: "danger" })

확인 창

되돌릴 수 없는 일만 한 번 더 물어요(dsConfirm). 확인 버튼은 '확인'이 아니라 제목의 동사로 써요.

보통 · 확인 버튼이 주버튼, 처음 초점은 확인
위험 · 빨간 확인 버튼, 처음 초점은 '그대로 두기'
처리 중과 실패 · 첫 번째는 일부러 실패해 창 안에 이유를 적어요
JS
// 되돌릴 수 없는 일만. 확인 버튼 글은 제목의 동사, 닫는 버튼은 아무것도 안 한다는 말
const ok = await dsConfirm({
  title: "이 노트를 지울까요?",
  desc: "첨부 파일 3개와 댓글 2개가 함께 지워지고 되돌릴 수 없어요.",
  confirm: "지우기", cancel: "그대로 두기", tone: "danger",   // danger 면 빨간 버튼, 처음 초점은 '그대로 두기'
})
if (ok) dsToast("노트를 지웠어요")

// 보통(주버튼 색). cancel 을 비우면 '취소', 확인 글에 '취소'가 있으면 '닫기'가 된다
await dsConfirm({ title: "박도윤 책임연구원에게 검토를 요청할까요?", confirm: "요청하기", cancel: "나중에" })

// 확인 뒤 서버 일을 창 안에서 기다린다. 끝날 때까지 버튼이 돌고, 실패하면 이유를 적고 창을 열어 둔다
await dsConfirm({
  title: "이 노트에 서명할까요?", confirm: "서명하기",
  onConfirm: () => fetch(signUrl, { method: "POST" }).then(r => { if (!r.ok) throw new Error("서명하지 못했어요. 한 번 더 눌러 주세요.") }),
})

알림 창

정보만 알리고 고를 것이 없을 때 써요(dsAlert). 버튼은 '확인' 하나뿐이에요. '확인'은 이 창에서만 써요.

제목은 알릴 사실, 설명은 그다음 할 일이에요.

JS
// 고를 것이 없는 안내. 버튼은 '확인' 하나(이 창에서만 '확인'을 쓴다)
await dsAlert({
  title: "서명은 한 번만 할 수 있어요",
  desc: "서명한 뒤에는 내용을 고칠 수 없어요. 고칠 게 있으면 새 버전으로 기록해 주세요.",
})

마크업으로 붙이는 확인

버튼·링크·폼에 data-confirm 만 달면 누르기 전에 같은 확인 창이 떠요. 확인하면 원래 동작(이동·제출)을 그대로 이어요.

ui_button confirm:
링크 · data-confirm-ok 작성 멈추고 나가기
폼 · data-turbo-confirm

data-confirm-ok 를 빠뜨리면 누른 버튼의 글자(12자 이하)가 확인 버튼이 돼요. DELETE 로 보내는 링크·폼은 tone 을 적지 않아도 위험 색이에요.

ERB
<%# 버튼(헬퍼). 확인 버튼 글은 data-confirm-ok %>
<%= ui_button "v2로 되돌리기", confirm: "지금 버전(v3)의 바뀐 내용 4곳이 새 버전으로 남고, v2 내용이 v4가 돼요.",
      confirm_title: "v2 내용으로 되돌릴까요?", data: { confirm_ok: "되돌리기", confirm_cancel: "그대로 두기" } %>

<%# 링크: 확인하면 원래 링크로 이동한다 %>
<a class="btn btn--outline" href="<%= notes_path %>" data-confirm="아직 저장하지 않은 내용 2곳이 사라져요."
   data-confirm-title="작성을 멈추고 나갈까요?" data-confirm-ok="나가기" data-confirm-tone="danger">작성 멈추고 나가기</a>

<%# 폼: Turbo 의 data-turbo-confirm 도 같은 창으로 그린다 %>
<%= button_to "검토 요청 보내기", review_requests_path, class: "btn btn--secondary",
      form: { data: { turbo_confirm: "검토가 끝날 때까지 노트가 잠겨요.", confirm_title: "검토를 요청할까요?", confirm_ok: "요청하기" } } %>

<%# DELETE 링크는 tone 을 적지 않아도 위험 색 %>
<%= ui_button "노트 지우기", href: note_path(@note), method: :delete, variant: "danger",
      confirm: "첨부 파일 3개가 함께 지워져요.", confirm_title: "이 노트를 지울까요?", data: { confirm_ok: "지우기" } %>

토스트 모양

이미 일어난 일을 과거형 한 줄로 알려요. 완료·오류는 면 색과 앞 아이콘이 함께 바뀌어요.

띄워 보기

한 번에 하나씩 보이고 나머지는 차례를 기다려요. 기본 4초, 되돌리기가 있으면 6초. 마우스를 올리거나 초점이 들어가면 멈춰요.

오류는 urgent 로 띄워 role="alert" 가 돼요. 화면 읽기가 하던 말을 끊고 바로 읽어요. 나머지는 role="status" 예요.

JS
dsToast("노트를 저장했어요")                                            // 기본 4초, role="status"
dsToast("노트에 서명했어요", { tone: "good" })                           // 완료: 초록 면 + 체크 아이콘
dsToast("저장하지 못했어요. 인터넷 연결을 확인해 주세요", { tone: "bad", urgent: true })   // 오류: role="alert"
dsToast("노트를 보관했어요", { action: "되돌리기", onAction: unarchive })   // 되돌리기가 있으면 6초
dsToast("링크를 복사했어요", { duration: 3000 })

// 여러 번 부르면 하나씩 차례로(큐). 시트·창이 열려 있으면 화면 위쪽에 뜬다
// 마우스를 올리거나 초점이 들어가면 멈추고, 벗어나면 남은 시간만큼 다시 센다

서버 flash 토스트

보내고 다른 화면으로 돌아가는 흐름에서는 컨트롤러의 notice·alert 가 토스트가 돼요. 레이아웃이 한 번 그려요.

누르면 서버에 보냈다가 이 화면(#overlays)으로 새로 들어와요. alert 는 빨간 오류 토스트로, 화면 읽기가 바로 읽어요.

Rails
# 컨트롤러: 보내고 돌아오는 흐름은 flash 가 토스트가 된다(레이아웃의 shared/_flash 가 그린다)
def update
  if @setting.update(setting_params)
    redirect_to settings_path, notice: "설정을 저장했어요"
  else
    redirect_to settings_path, alert: "저장하지 못했어요. 인터넷 연결을 확인해 주세요"   # 빨간 토스트, 화면 읽기가 바로 읽는다
  end
end

# 색을 고를 때
flash[:toast] = { message: "초대를 보냈어요", tone: "good" }

<%# 보내는 버튼 %>
<%= button_to "설정 저장하기", setting_path, method: :patch, class: "btn btn--secondary" %>

Turbo Stream 토스트

화면을 바꾸지 않는 요청(검토 요청·좋아요)은 응답에 toast_stream 하나만 담아 제자리에서 알려요.

보내는 동안 Turbo 가 폼을 aria-busy 로 두어 버튼이 저절로 처리 중 모양이 돼요.

Rails
# 컨트롤러: 화면을 바꾸지 않는 요청은 Turbo Stream 으로 토스트만 붙인다(#flash 자리에 shared/_toast)
def create
  @note.review_requests.create!(reviewer: @reviewer)
  render turbo_stream: helpers.toast_stream("검토 요청을 보냈어요", tone: "good")
end

# 다른 조각과 함께
render turbo_stream: [
  turbo_stream.replace(@note, partial: "notes/note", locals: { note: @note }),
  helpers.toast_stream("검토 요청을 보냈어요", tone: "good")
]

<%# 보내는 버튼(보내는 동안 form[aria-busy] 라 버튼이 처리 중 모양) %>
<%= button_to "검토 요청하기", note_review_requests_path(@note), class: "btn btn--secondary" %>

열고 닫을 때 키트가 하는 일

시트·창·확인 창 모두 같아요. 따로 만들지 않아요.

동작어떻게
처음 초점autofocus 가 있으면 거기, 없으면 패널 자체. 화면 읽기는 제목을 읽어요
초점 가두기Tab 은 첫 컨트롤, Shift+Tab 은 마지막 컨트롤로 돌아요. 맨 위 창만 가둬요
닫기Escape·바깥 덮개·data-close 버튼·손잡이 끌기(90px). 겹쳤으면 맨 위 하나만
초점 돌려놓기닫히면 연 버튼으로 초점이 돌아가요
배경 잠금뒤 화면이 스크롤되지 않고, 스크롤바 폭만큼 오른쪽을 채워 화면이 튀지 않아요
잘못 누름 막기누르는 순간 열린 시트는 손을 뗄 때 생긴 누름을 버려요. 열리자마자 닫히지 않아요

창과 토스트의 문구

질문과 답이 짝이 되게 써요(10_writing).

자리규칙 · 예
토스트이미 한 일을 과거형 한 줄로저장됩니다 → 노트를 저장했어요
확인 창 제목할 일을 '-할까요?' 로 묻기삭제하시겠습니까? → 이 노트를 지울까요?
확인 버튼제목의 동사 그대로확인 → 지우기 · 서명하기 · 요청하기
닫는 버튼아무것도 안 한다는 뜻을 말로아니요 → 그대로 두기 · 나중에 · 계속 쓰기
설명누르면 무엇이 어떻게 되는지 숫자로첨부 파일 3개와 댓글 2개가 함께 지워져요
오류 토스트원인과 할 일저장 실패 → 저장하지 못했어요. 인터넷 연결을 확인해 주세요

14레이아웃

화면 골격은 레이아웃 넷 중에서 골라요

컨트롤러에서 layout 하나만 정하면 내비·탭바·사이드바·푸터가 따라와요. 화면은 본문과 content_for 자리만 채워요.

네 가지 레이아웃

누가 어떤 기기로 보는 화면인지로 골라요. 카드를 누르면 아래에서 실제 골격을 볼 수 있어요.

Ruby
# 컨트롤러에서 layout 하나만 고른다. 화면(뷰)은 본문과 content_for 자리만 채운다.
class PagesController < ApplicationController
  layout "marketing"      # 공개 사이트: 진행 막대 · 내비 · 푸터 · 맨 위로
end

class App::BaseController < ApplicationController
  layout "app"            # 로그인 뒤 모바일 앱: 앱바 · 본문 스크롤 · 탭바
end

class Admin::BaseController < ApplicationController
  layout "admin"          # 관리자·운영: 사이드바 · 위쪽 막대 · 표
end

class SessionsController < ApplicationController
  # layout 을 적지 않으면 application: 내비·탭바 없는 단순 화면(로그인·약관 인쇄본)
end

content_for 자리

레이아웃이 비워 둔 자리예요. 화면에서 채우지 않으면 그 부분을 그리지 않아요.

자리하는 일
page_title:title 브라우저 제목 '이름 · 연구대장'. page_title 은 값을 돌려줘서 h1 에도 그대로 써요
:description 공유 카드와 검색 결과에 보이는 한 줄 설명
:og_title:og_image:og_no_image 공유 카드의 제목·그림을 바꾸거나 빼요
:head 머리말에 더할 것(미리 불러오기, 화면 전용 meta)
:overlays 시트·창. 본문 스크롤 밖에 두어 겹침 순서가 꼬이지 않아요
ERB
<%# 모든 레이아웃 %>
<% page_title "PLA 필름 인장 강도 측정" %>              <%# 브라우저 제목: PLA 필름 인장 강도 측정 · 연구대장 %>
<% content_for :description, "실험 과정과 결과를 한 장에 남겨요." %>
<% content_for :overlays do %>
  <%= ui_sheet "note-more", title: "이 노트로 할 수 있는 일" do %> … <% end %>
<% end %>

<%# marketing %>
<% content_for :nav_float, true %>     <%# 첫 화면 빛이 내비 밑까지. 첫 섹션에 under-nav %>
<% content_for :railmark, true %>      <%# 왼쪽 진행 눈금(1440px 이상) %>
<% content_for :no_chrome, true %>     <%# 내비·푸터 없이 본문만 %>

<%# app %>
<% app_bar "오늘", brand: true, sub: "9월 28일 월요일 · 고분자 연구실" %>
<% content_for :tab, "home" %>
<% content_for :no_tabbar, true %>
<% app_actionbar hint: "서명한 뒤에는 고칠 수 없어요" do %> … <% end %>
<% app_dock do %> … <% end %>

<%# admin %>
<% content_for :admin_search_url, admin_notes_path %>
<% content_for :admin_context do %> … <% end %>
<% content_for :admin_bare, true %>     <%# 관리자 로그인: 사이드바 없이 가운데 카드 %>

공개 사이트 골격 · marketing

지금 보고 있는 이 화면이 marketing 레이아웃이에요. 그림의 번호와 아래 설명의 번호가 같아요.

  1. 진행 막대 .progressbar 맨 위 2px. 읽은 만큼 차요
  2. 내비 · 서랍 shared/_nav 내리면 흐린 유리. 좁은 화면은 메뉴 버튼이 서랍을 열어요
  3. 본문 <main id="main"> 화면이 yield 로 채워요. '본문으로 건너뛰기'가 여기로 와요
  4. 하단 고정 CTA 바 :dockbar 반쯤 내려가면 떠올라요
  5. 맨 위로 .totop 한 화면 넘게 내려가면 나타나요
  6. 푸터 shared/_footer 메뉴·약관·테마 버튼. 보이면 하단 바가 비켜서요
  7. 왼쪽 진행 눈금 :railmark 1440px 이상에서만. 구역마다 눈금 하나
ERB
<%# app/views/pages/about.html.erb (layout "marketing") %>
<% page_title "연구대장 소개" %>
<% content_for :description, "실험 기록부터 서명까지 한곳에서 끝내요." %>
<% content_for :nav_float, true %>
<% content_for :railmark, true %>

<section class="hero under-nav" data-rail-section="소개" data-dock-hide> … </section>
<section class="section surface-alt" data-rail-section="기능"> … </section>
<section class="section" data-rail-section="요금"> … </section>

<%# 레이아웃이 그리는 것: 진행 막대 · 내비(shared/_nav) · 서랍 · <main id="main"> · 하단 CTA 바 · 맨 위로 · 푸터(shared/_footer)
    메뉴 이름과 목적지는 NavigationHelper(site_nav_items · site_footer_groups)에서만 고친다. %>

떠 있는 크롬

스크롤에 따라 나타나고 비켜서요. '짚어 보기'를 누르면 이 화면의 실제 요소에 테두리가 잠깐 둘러져요.

  • 위쪽 진행 막대 문서를 읽은 만큼 차요. 모든 공개 화면 맨 위에 늘 있어요.
  • 왼쪽 진행 눈금 1440px 이상 넓은 화면에서만 보여요. 구역마다 눈금이 하나 생기고, 누르면 그 구역으로 가요.
  • 맨 위로 한 화면 넘게 내려가면 나타나요. 하단 바가 떠 있으면 휴대폰에서는 숨고, 태블릿에서는 바 위로 비켜서요.
  • 하단 고정 CTA 바 화면 절반쯤 내려가면 떠오르고, 푸터처럼 가리면 안 되는 구역이 보이면 비켜서요. 이 화면에는 '주소 복사하기' 바를 넣었어요.
  • 안전 영역 노치·홈 표시줄에 버튼이 가리지 않게 비워 두는 자리예요. 두 토큰이 기기 값(env)을 읽어요.
ERB
<%# 하단 고정 CTA 바: 반쯤 내려가면 떠오르고, data-dock-hide 구역(첫 화면 버튼·마지막 CTA·푸터)이 보이면 비켜선다 %>
<% content_for :dockbar do %>
  <div class="dockbar" data-dockbar role="region" aria-label="검토 의견 보내기">
    <p class="dockbar__text"><strong>검토 의견이 있나요?</strong>주소를 붙여 보내 주세요</p>
    <%= ui_button "주소 복사하기", variant: "primary", pill: true, icon_name: "copy",
          data: { copy: design_system_url, copy_message: "화면 주소를 복사했어요" } %>
  </div>
<% end %>

<%# 왼쪽 진행 눈금: 레이아웃에 켜고, 셀 구역마다 이름을 단다 %>
<% content_for :railmark, true %>
<section class="section" data-rail-section="요금"> … </section>

<%# 맨 위로 · 진행 막대는 marketing 레이아웃이 늘 그린다. 가려야 하는 구역에만 표시를 단다 %>
<div class="scene__stage" data-dock-hide data-totop-hide> … </div>

/* 안전 영역: viewport 에 viewport-fit=cover 를 두고 토큰으로 비운다 */
.my-bottom-bar { padding-bottom: calc(var(--s-12) + var(--safe-b)); }

모바일 앱 골격 · app

로그인 뒤 휴대폰 화면이에요. 틀 안은 실제 /design-system/app 이라 스크롤하면 큰 제목이 접히고, 시트·동작 바·입력 바가 그대로 움직여요.

새 창에서 열기
앱바 app_bar
뒤로 · 제목 · 동작 하나. 스크롤하면 흐린 유리가 깔리고 가운데 작은 제목이 나타나요.
큰 제목 sub:
본문 맨 위에서 함께 스크롤되다가 앱바로 접혀요. 상세처럼 제목이 따로 있으면 large: false.
본문 .app__body
문서가 아니라 여기만 스크롤해요. 폭은 container--narrow 로 시작해요.
하단 tabbar
탭 화면은 탭바(확인할 일 수 배지), 결론이 있는 화면은 동작 바 app_actionbar, 입력이 이어지는 화면은 입력 바 app_dock.
넓은 화면
1024px 부터는 앱을 늘리지 않고 가운데 640px 기둥에 담아요.

틀 안에서는 노치와 홈 표시줄 자리(안전 영역)를 48·34px 로 흉내 내요. 새 창에서는 기기 값을 그대로 써요.

ERB
<%# 홈: app/views/app/home/show.html.erb (layout "app") %>
<% app_bar "오늘", brand: true, sub: "9월 28일 월요일 · 고분자 연구실" do %>
  <%= appbar_action "bell", label: "알림", count: @unread, data: { open: "alerts" } %>
<% end %>
<div class="container container--narrow stack stack--lg">
  <%= ui_callout(tone: "warn", title: "서명할 노트가 2개 있어요") { "박도윤 책임연구원이 검토를 마쳤어요." } %>
  <%= ui_group "오늘 쓴 노트", more: [ "모두 보기", notes_path ] do %>
    <% @notes.each do |n| %>
      <%= row_item title: n.title, sub: "#{ko_time(n.updated_at, with_date: false)} · 첨부 #{n.files_count}개",
                   lead: icon("doc"), tail: ui_badge(n.status_label, n.status_tone), href: note_path(n), chev: true %>
    <% end %>
  <% end %>
</div>

<%# 상세: 뒤로 · 작은 제목 · 하단 동작 바(탭바 대신) %>
<% app_bar "연구노트", back: notes_path, large: false %>
<% app_actionbar hint: "서명한 뒤에는 이 버전을 고칠 수 없어요" do %>
  <%= ui_button "검토 요청", size: "lg", data: { open: "review-sheet" } %>
  <%= ui_button "서명하기", variant: "primary", size: "lg", confirm_title: "이 버전에 서명할까요?",
        confirm: "서명하면 v3 을 고칠 수 없어요.", data: { confirm_ok: "서명하기" } %>
<% end %>

<%# 일지: 탭바 없이 입력 바만 %>
<% content_for :no_tabbar, true %>
<% app_dock do %>
  <div class="dock dock--static"> … textarea.dock__input · button.dock__send … </div>
<% end %>

관리자 골격 · admin

연구실 관리자가 넓은 화면에서 쓰는 화면이에요. 1280px 화면을 줄여서 보여 줘요. 행을 누르거나 체크하고, 상태·과제로 걸러 보세요.

관리자 화면은 넓은 화면에서 봐요

1280px 로 그린 화면이라 여기에는 줄여 넣지 않았어요. 새 창으로 열면 휴대폰에서는 사이드바가 서랍으로, 표가 카드 목록으로 바뀌어요.

새 창에서 열기
  • 사이드바admin_nav_groups 메뉴. 1023px 이하는 서랍
  • 위쪽 막대검색(⌘K · /) · 테마 · 계정 메뉴
  • 본문page-head → kpi → 거르기 → 표
  • 오른쪽 열admin_context. 1280px 부터 셋째 열
새 창에서 열기
ERB
<%# app/views/admin/dashboard/show.html.erb (layout "admin") %>
<% page_title "연구노트 현황" %>
<% content_for :admin_search_url, admin_notes_path %>
<% content_for :admin_search_placeholder, "노트 번호·제목·작성자" %>
<% content_for :admin_context do %>
  <%= render "admin/dashboard/due_soon" %>     <%# 1280px 부터 오른쪽 셋째 열 %>
<% end %>

<header class="page-head">
  <div class="page-head__text">
    <h1 class="page-head__title">연구노트 현황</h1>
    <p class="page-head__sub">9월 28일 월요일 · 10:24 기준</p>
  </div>
  <div class="page-head__actions">
    <div class="segment" role="group" aria-label="기간"> … </div>
    <%= ui_button "내보내기", icon_name: "download" %>
    <%= ui_button "구성원 초대", variant: "primary", icon_name: "plus", href: new_admin_user_path %>
  </div>
</header>
<section class="kpi" aria-label="이번 주 숫자"> … </section>
<section class="card card--line table-card"> .filters · table.table-dense[data-table] · .table-card__foot </section>
<div class="bulkbar" data-bulkbar="notes-table" hidden> … </div>

로그인 화면 · application

메뉴 없이 한 가지만 하는 화면이에요. 휴대폰은 한 장, 768px 부터는 가운데 카드가 돼요. 빈 칸으로 로그인을 눌러 보세요.

새 창에서 열기

로고 → 한 줄 제목 → 칸 → 주버튼 하나 → 외부 서비스 → 약관 순서예요. 폼을 보내지 않는 예시라 로그인하면 앱 홈 미리보기로 가요.

ERB
<%# app/views/sessions/new.html.erb (layout "application": 내비·탭바 없음) %>
<% page_title "로그인" %>
<div class="auth">
  <div class="auth__bar"> 뒤로 · 테마 버튼 </div>
  <div class="auth__card">
    <%= link_to root_path, class: "nav__brand", "aria-label": "연구대장 처음으로" do %><%= render "shared/brand", size: 30 %><% end %>
    <div class="auth__head">
      <h1 class="t-heading-lg ink">연구대장에 로그인해요</h1>
      <p class="t-body-sm ink-muted">연구실에서 받은 계정 이메일로 로그인해요.</p>
    </div>
    <%= ds_form_with url: session_path, class: "stack" do |f| %>
      <%= f.email_field :email, label: "이메일", placeholder: "예: jisu.kim@hanbitbio.kr" %>
      <%= f.password_field :password, label: "비밀번호" %>
      <%= f.submit "로그인", size: "lg", block: true %>
    <% end %>
    <p class="auth__or" role="separator">또는</p>
    <div class="auth__social">
      <%= link_to "/auth/kakao", class: "btn btn--outline btn--lg btn--block" do %><%= provider_mark "kakao", size: 20 %>카카오로 계속하기<% end %>
      <%= link_to "/auth/google", class: "btn btn--outline btn--lg btn--block" do %><%= provider_mark "google", size: 20 %>Google로 계속하기<% end %>
    </div>
    <p class="auth__foot">계속하면 <a href="/terms">이용약관</a>과 <a href="/privacy">개인정보 처리방침</a>에 동의하는 것으로 봐요.</p>
  </div>
</div>

오류 페이지

public/ 의 정적 파일이에요. 앱이 멈춰도 열리도록 레이아웃·스크립트 없이 /error.css 하나만 써요.

  • 404 · 없는 주소주소가 바뀌었거나 지운 페이지
  • 500 · 서버 문제잠깐 뒤 다시 열어 달라고 알려요
  • 422 · 처리하지 못한 요청오래 열어 둔 화면에서 보낼 때
  • 406 · 지원하지 않는 브라우저오래된 브라우저에서 열 때

400.html 도 같은 모양이에요. 문구는 원인과 할 일을 말하고, 사과는 서비스 잘못(500)일 때만 짧게 해요.

HTML
<!-- public/404.html · 500.html · 422.html · 400.html · 406-unsupported-browser.html
     레이아웃·스크립트 없이 열린다. 앱이 멈춰도 보이도록 정적 파일 하나와 /error.css 만 쓴다. -->
<link rel="stylesheet" href="/error.css">
<main>
  <a href="/" class="brand" aria-label="연구대장 홈"> … </a>
  <span class="code">404</span>
  <h1>찾으시는 페이지가 없어요</h1>
  <p>주소가 바뀌었거나 없어진 페이지예요. 주소를 다시 확인하거나 홈에서 찾아 주세요.</p>
  <div class="actions"><a class="button" href="/">홈으로 가기</a> … </div>
</main>

15섹션

소개 페이지는 블록을 골라 쌓아요

메인·소개·요금 화면에 쓸 블록을 실제 크기로 모았어요. 문구는 연구대장 랜딩을 가정한 예시이고, 정해지지 않은 숫자와 약속에는 '예시'를 붙였어요.

블록 고르기

필요한 블록으로 바로 가요. 번호는 랜딩 한 장에 쌓을 때 권하는 순서이고, 점은 그 블록이 쓰는 면이에요.

히어로 · 가운데 정렬

메인 페이지 첫 화면이에요. 새 소식 알약, 두 줄 제목, 버튼 둘, 쓰는 사람 줄을 가운데로 모아요.

.hero.hero--center흰 바탕 · 빛무리 · 글자가 차례로 올라와요예시 문구
새 기능검토 요청을 알림으로 받아요

연구 기록은 쉽게, 증빙은 확실하게.

실험하면서 적은 메모와 사진이 그대로 연구노트가 돼요. 작성자·검토자 서명과 버전 기록까지 한 화면에서 끝나요.

연구실 120곳에서 쓰고 있어요 예시

첫 화면 글자는 페이지를 열 때 한 번만 올라와요(rise). 아래에 무대를 붙이면 랜딩 첫 화면이 돼요.

ERB
<%# 메인 첫 화면 · 가운데 정렬. 첫 섹션이면 화면에 content_for :nav_float, true 를 주고 under-nav 를 붙인다 %>
<section class="hero hero--center under-nav surface-base">
  <div class="bg-aurora" aria-hidden="true"><span class="bg-aurora__blob"></span><span class="bg-aurora__blob"></span><span class="bg-aurora__blob"></span></div>
  <div class="dotfield" aria-hidden="true"></div>
  <div class="container container--wide">
    <div class="hero__copy">
      <a class="hero__badge rise" href="/product/sign">
        <%= ui_badge "새 기능", "accent", pill: true %><span>검토 요청을 알림으로 받아요</span><%= icon "arrow-right", size: "sm" %>
      </a>
      <h1 class="hero__title t-display-xl">
        <span class="title-line rise rise--lg rise--2">연구 기록은 쉽게,</span>
        <span class="title-line rise rise--lg rise--3"><em>증빙은 확실하게.</em></span>
      </h1>
      <p class="hero__lede rise rise--4">실험하면서 적은 메모와 사진이 그대로 연구노트가 돼요.</p>
      <div class="hero__actions btn-row btn-row--fill rise rise--5" data-dock-hide>
        <%= ui_button "무료로 시작하기", variant: "primary", size: "lg", href: "/signup", icon_name: "arrow-right", icon_after: true %>
        <%= ui_button "어떻게 쓰는지 보기", size: "lg", href: "#how", icon_name: "play" %>
      </div>
      <p class="hero__meta rise rise--6">
        <span class="avatars avatars--sm" aria-hidden="true">
          <span class="avatar--text avatar--accent">김</span><span class="avatar--text avatar--good">박</span><span class="avatar--text avatar--note">이</span>
        </span>
        <span><strong>연구실 120곳</strong>에서 쓰고 있어요</span>
      </p>
    </div>
    <%# 아래에 무대(.stage)를 붙이면 랜딩 첫 화면이 된다 %>
  </div>
</section>

히어로 · 두 칸과 폰 목업

제품 화면을 바로 보여 줄 때 써요. 1024px 부터 왼쪽 글·오른쪽 폰, 좁은 화면은 글 아래에 폰이 와요.

.hero.hero--split흰 바탕 · hero__grid · 폰 안은 HTML 목업예시 문구
전자연구노트

흩어진 실험 기록, 과제별로 한곳에.

실험 노트와 시약 로트, 장비 사용 기록을 과제마다 모아요. 누가 언제 무엇을 고쳤는지 버전으로 남아요.

  • 설치 없이 브라우저에서
  • 사진·파일 원본 그대로
  • 작성자·검토자 서명

앱 화면 예시: 9월 28일 오늘의 연구노트. 작성 중 3건, 검토 대기 2건, 서명 완료 5건이고 시약 로트 변경 확인 노트에 서명이 끝났어요.

ERB
<section class="hero hero--split surface-base">
  <div class="bg-aurora" aria-hidden="true"><span class="bg-aurora__blob"></span><span class="bg-aurora__blob"></span><span class="bg-aurora__blob"></span></div>
  <div class="container container--wide">
    <div class="hero__grid">
      <div>
        <span class="eyebrow">전자연구노트</span>
        <h1 class="hero__title t-display-lg">
          <span class="title-line">흩어진 실험 기록,</span>
          <span class="title-line"><em>과제별로 한곳에.</em></span>
        </h1>
        <p class="hero__lede">실험 노트와 시약 로트, 장비 사용 기록을 과제마다 모아요.</p>
        <div class="hero__actions btn-row btn-row--fill" data-dock-hide>
          <%= ui_button "무료로 시작하기", variant: "primary", size: "lg", href: "/signup" %>
          <%= ui_button "도입 상담 받기", size: "lg", href: "/contact" %>
        </div>
      </div>
      <div class="stage">
        <div class="stage__glow" aria-hidden="true"></div>
        <p class="sr-only">앱 화면 예시: 오늘의 연구노트. 작성 중 3건, 검토 대기 2건 …</p>
        <div class="phone" aria-hidden="true">
          <div class="phone__notch"></div>
          <div class="phone__screen">
            <div class="mock">
              <div class="mock__status"><span>9:41</span><span class="mock__sig"><span></span><span></span><span></span></span></div>
              <div class="mock__head">
                <div><span class="mock__kicker">9월 28일 월요일</span><span class="mock__title">오늘의 연구노트</span></div>
                <span class="avatar avatar--text avatar--accent">지수</span>
              </div>
              <div class="mock__body">
                <div class="mock__sum"><span>작성 중<b>3</b></span><span>검토 대기<b class="is-accent">2</b></span><span>서명 완료<b>5</b></span></div>
                <div class="mock__row">
                  <span class="mock__time">09:40</span>
                  <span class="mock__main"><span class="mock__name">PCR 증폭 조건 비교 3차</span><span class="mock__sub">항체 후보 선별 · 첨부 4개</span></span>
                  <span class="mock__tag mock__tag--accent">작성 중</span>
                </div>
                …
              </div>
              <div class="mock__tabbar"><span class="mock__tab mock__tab--on"><%= icon "home" %>홈</span>…</div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</section>

하위 페이지 머리

요금·도입 사례·보안 같은 하위 페이지의 첫 구역이에요. 가운데 제목과 은은한 빛만 두고, 안에서 옮길 곳은 알약 버튼으로 줘요.

.page-hero흰 바탕 · 빛무리 · 점 격자예시 문구
도입 사례

먼저 쓴 연구실의이야기를 모았어요

대학 연구실부터 기업 부설 연구소까지, 종이 노트에서 옮겨 온 과정과 달라진 점을 정리했어요.

ERB
<%# 하위 페이지(요금·도입 사례·보안)의 첫 구역. nav--float 아래면 under-nav %>
<section class="page-hero under-nav surface-base">
  <div class="bg-aurora" aria-hidden="true"><span class="bg-aurora__blob"></span><span class="bg-aurora__blob"></span><span class="bg-aurora__blob"></span></div>
  <div class="dotfield" aria-hidden="true"></div>
  <div class="container container--wide">
    <div class="section-head section-head--center">
      <span class="eyebrow rise">도입 사례</span>
      <h1 class="t-display-lg ink">
        <span class="title-line rise rise--lg rise--2">먼저 쓴 연구실의</span>
        <span class="title-line rise rise--lg rise--3">이야기를 모았어요</span>
      </h1>
      <p class="section-lede rise rise--4">대학 연구실부터 기업 부설 연구소까지 …</p>
      <nav class="btn-row btn-row--center section-body--xs rise rise--5" aria-label="도입 사례 묶음">
        <a class="btn btn--secondary btn--md btn--pill" href="#stories">연구원 이야기</a>
        <a class="btn btn--secondary btn--md btn--pill" href="#numbers">숫자로 본 변화</a>
      </nav>
    </div>
  </div>
</section>

무대

가운데 정렬 히어로 아래에 붙이는 폰 목업과 떠 있는 조각이에요. 내리는 동안 눕혀진 무대가 바로 서고, 조각은 서로 다른 세기로 움직여요.

.stagedata-scrub="tiltin" · 조각마다 data-parallax예시 문구

앱 화면 예시: PCR 증폭 조건 비교 노트 버전 3. 김지수 연구원이 작성자 서명을 했고, 박도윤 책임연구원의 검토 서명을 기다리고 있어요.

900px 미만에서는 조각 둘(왼쪽 위·오른쪽 아래)만 남아 폰 가장자리에 걸쳐요. 폰은 aria-hidden, 같은 내용을 sr-only 문장으로 곁에 둬요.

ERB
<%# 가운데 히어로(.hero__copy) 바로 아래. 조각은 넷(tl·tr·bl·br), 900px 미만은 tl·br 만 남는다 %>
<div class="stage" data-scrub="tiltin">
  <div class="stage__glow" aria-hidden="true"></div>
  <p class="sr-only">앱 화면 예시: PCR 증폭 조건 비교 노트 버전 3. 작성자 서명을 했고 검토 서명을 기다리고 있어요.</p>
  <div class="phone" aria-hidden="true">
    <div class="phone__notch"></div>
    <div class="phone__screen"><div class="mock">…</div></div>
  </div>
  <div class="stage__chip stage__chip--tl" data-parallax="0.08" aria-hidden="true">
    <div class="stage__card">
      <span class="stage__icon"><%= icon "edit" %></span>
      <span class="stage__text"><span class="stage__k">방금 저장했어요</span><span class="stage__v">버전 3 · 14:02</span></span>
    </div>
  </div>
  <div class="stage__chip stage__chip--tr" data-parallax="-0.06" aria-hidden="true">…stage__icon--good…</div>
  <div class="stage__chip stage__chip--bl" data-parallax="-0.1" aria-hidden="true">…stage__icon--note…</div>
  <div class="stage__chip stage__chip--br" data-parallax="0.12" aria-hidden="true">…</div>
</div>

로고 띠

쓰는 곳을 보여 줄 때 히어로 바로 아래에 둬요. 같은 두 묶음이 끊김 없이 흐르고, 화면 밖·모션 축소에서는 멈춰요.

.logos + .ticker[data-ticker]흰 바탕 · 실제 로고 대신 가상의 연구소 이름예시 문구

대학 연구실부터 기업 연구소까지 먼저 쓰고 있어요

  • 한빛바이오 연구소
  • 누리소재 연구실
  • 다온화학 분석실
  • 아라식품 연구소
  • 새봄에너지 기술원
  • 청람의과학 연구센터
  • 고운세포 연구실
  • 바름진단 연구소

움직이는 내용은 멈출 수 있어야 해요. 오른쪽 위 버튼으로 멈추면 이 탭을 닫을 때까지 멈춘 채로 있어요. 마우스를 올리거나 초점이 들어와도 멈춰요.

ERB
<%# 같은 두 묶음을 이어 붙여야 -50% 에서 끊김 없이 돈다. 두 번째 묶음은 aria-hidden %>
<% logos = [ [ "brain", "한빛바이오 연구소" ], [ "chart", "누리소재 연구실" ], [ "search", "다온화학 분석실" ] ] %>
<section class="logos surface-base" aria-labelledby="logos-title">
  <div class="container container--wide">
    <div class="logos__head">
      <p class="logos__title" id="logos-title">대학 연구실부터 기업 연구소까지 먼저 쓰고 있어요</p>
      <button class="btn btn--ghost btn--sm btn--square ticker-toggle" type="button" data-ticker-toggle
              aria-controls="logo-ticker" aria-pressed="false" aria-label="움직임 멈추기"><%= icon "pause", size: "sm" %></button>
    </div>
  </div>
  <div class="ticker" id="logo-ticker" data-ticker>
    <div class="ticker__track">
      <ul class="logos__group" aria-label="쓰고 있는 곳">
        <% logos.each do |ic, name| %><li class="logos__item"><%= icon ic %><%= name %></li><% end %>
      </ul>
      <ul class="logos__group" aria-hidden="true">
        <% logos.each do |ic, name| %><li class="logos__item"><%= icon ic %><%= name %></li><% end %>
      </ul>
    </div>
  </div>
</section>

문제 제시

우리가 푸는 문제를 공감으로 먼저 꺼낼 때 써요. 큰 문장은 내리는 만큼 단어가 차례로 진해지고, 겪는 일은 알약으로 늘어놓아요.

.problem · .lit · .pains들어간 면 · 1024px 부터 두 칸예시 문구
연구노트, 이렇게 쓰고 있다면

실험은 끝났는데 기록은 종이 노트와 메신저, 공유 폴더에 흩어져 있어요. 감사가 다가오면 누가 언제 고쳤는지 다시 찾고 있지 않나요?

  • 노트에 풀로 붙인 젤 사진
  • 파일 이름으로 나누는 버전
  • 도장 받으러 기다리는 검토
  • 퇴사자 PC에 남은 원본
  • 감사 때마다 다시 찾는 기록
ERB
<section class="section section--lg surface-alt on-alt">
  <div class="container container--wide">
    <div class="problem">
      <div>
        <span class="eyebrow" data-reveal>연구노트, 이렇게 쓰고 있다면</span>
        <%# .lit 안에는 글자만 둔다(fx.js 가 단어로 쪼갠다) %>
        <p class="lit t-display-sm ink section-body--sm">실험은 끝났는데 기록은 종이 노트와 메신저, 공유 폴더에 흩어져 있어요. …</p>
      </div>
      <ul class="pains" aria-label="자주 겪는 일" data-reveal data-reveal-group>
        <li class="pain"><span class="pain__icon"><%= icon "image", size: "sm" %></span>노트에 풀로 붙인 젤 사진</li>
        <li class="pain"><span class="pain__icon"><%= icon "copy", size: "sm" %></span>파일 이름으로 나누는 버전</li>
        …
      </ul>
    </div>
  </div>
</section>

기능 격자

기능을 고르게 나열할 때 써요. 카드가 아니라 아이콘·제목·설명만 두고, 640px 2열 · 1024px 3열(또는 4열)로 늘어요.

.features.features--3 > .feature흰 바탕 · 차례로 나타나기예시 문구
기능

연구노트에 필요한 건 다 들어 있어요

따로 배울 것 없이, 오늘 가입하면 오늘 한 실험부터 남길 수 있어요.

양식으로 바로 쓰기

PCR·세포 배양처럼 자주 하는 실험은 양식을 불러와 빈칸만 채워요.

사진·파일 그대로 첨부

젤 사진과 측정값 파일, 프로토콜 PDF를 원본 그대로 붙여요.

작성자·검토자 전자서명

서명한 노트는 잠겨서 고칠 수 없어요. 고치면 새 버전으로 남아요.

모든 변경은 버전으로

누가 언제 무엇을 바꿨는지 두 버전을 나란히 놓고 비교해요.

과제 단위 권한

과제 책임자가 볼 사람과 쓸 사람을 정해요.

감사용 PDF 내보내기

기간을 고르면 서명과 버전 기록이 담긴 PDF로 묶어요.

ERB
<section class="section surface-base">
  <div class="container container--wide">
    <div class="section-head" data-reveal>
      <span class="eyebrow">기능</span>
      <h2 class="t-display-sm ink">연구노트에 필요한 건 다 들어 있어요</h2>
      <p class="section-lede">따로 배울 것 없이, 오늘 가입하면 오늘 한 실험부터 남길 수 있어요.</p>
    </div>
    <%# 640px 2열 · 1024px 3열. 네 칸이면 features--4 %>
    <div class="features features--3 section-body" data-reveal data-reveal-group>
      <div class="feature">
        <span class="feature__icon"><%= icon "edit" %></span>
        <h3 class="feature__title">양식으로 바로 쓰기</h3>
        <p class="feature__desc">자주 하는 실험은 양식을 불러와 빈칸만 채워요.</p>
      </div>
      …
    </div>
  </div>
</section>

두 칸과 체크 목록

한 가지 약속을 여러 줄로 풀 때 써요. 1024px 부터 왼쪽 제목이 붙어 따라오고, 오른쪽에 체크 목록이 와요.

.split + .checks--cards들어간 면 · 제목이 붙어 따라와요(1024px~)예시 문구
검토와 서명

검토 요청부터 서명까지,자리에서 끝내요

책임연구원이 자리에 없어도 알림을 눌러 바로 열고, 고칠 곳은 댓글로 남겨요.

  • 검토자는 알림을 눌러 바로 열어요
  • 고칠 곳은 줄마다 댓글로 남겨요
  • 서명하면 노트가 잠기고 시각이 찍혀요
  • 반려하면 사유와 함께 작성자에게 돌아가요
서명한 뒤에 고칠 게 생기면

잠긴 노트는 그대로 두고 새 버전을 만들어요. 두 버전을 나란히 놓고 바뀐 줄만 볼 수 있어요.

ERB
<section class="section section--lg surface-alt on-alt">
  <div class="container container--wide">
    <%# 1024px 부터 두 칸, 제목이 붙어 따라온다. --center(세로 가운데) --reverse(좌우 바꿈) %>
    <div class="split">
      <div class="section-head" data-reveal>
        <span class="eyebrow">검토와 서명</span>
        <h2 class="t-display-sm ink"><span class="title-line">검토 요청부터 서명까지,</span><span class="title-line">자리에서 끝내요</span></h2>
        <p class="section-lede">책임연구원이 자리에 없어도 알림을 눌러 바로 열어요.</p>
      </div>
      <div class="split__list" data-reveal>
        <ul class="checks checks--cards t-body-md">
          <li><%= icon "bell" %><span>검토자는 알림을 눌러 바로 열어요</span></li>
          <li><%= icon "chat" %><span>고칠 곳은 줄마다 댓글로 남겨요</span></li>
        </ul>
        <div>
          <h3 class="t-heading-sm ink">서명한 뒤에 고칠 게 생기면</h3>
          <p class="t-body-md ink-muted">잠긴 노트는 그대로 두고 새 버전을 만들어요.</p>
        </div>
      </div>
    </div>
  </div>
</section>

단계 흐름

시작하는 순서나 처리 과정을 보여 줄 때 써요. 좁은 화면은 세로, 768px 부터 가로로 놓이고 잇는 선이 차례로 채워져요.

.steps-flow흰 바탕 · --n:4 · 보이면 선이 채워져요예시 문구
노트 한 장의 흐름

기록부터 검토까지,네 단계면 끝나요

  1. 1
    기록

    실험하면서 바로 적어요. 양식을 불러오면 빈칸만 채우면 돼요.

  2. 2
    첨부

    젤 사진과 측정값 파일, 프로토콜을 원본 그대로 붙여요.

  3. 3
    서명

    작성자가 서명하면 그 시각으로 노트가 잠겨요.

  4. 4
    검토

    책임연구원이 확인하고 서명하면 기록이 끝나요.

칸 수는 style="--n:4" 로 정해요. 모션 축소와 스크립트가 없을 때는 처음부터 선이 채워져 있어요.

ERB
<%# --n 이 칸 수. 좁은 화면은 세로(왼쪽 선), 768px 부터 가로(위쪽 선). 보이면(data-reveal) 선이 차례로 채워진다 %>
<ol class="steps-flow section-body--lg" style="--n:4" data-reveal data-reveal-group>
  <li class="steps-flow__item">
    <span class="steps-flow__no">1</span>
    <h3 class="steps-flow__title">기록</h3>
    <p class="steps-flow__desc">실험하면서 바로 적어요. 양식을 불러오면 빈칸만 채우면 돼요.</p>
  </li>
  <li class="steps-flow__item"><span class="steps-flow__no">2</span><h3 class="steps-flow__title">첨부</h3><p class="steps-flow__desc">…</p></li>
  <li class="steps-flow__item"><span class="steps-flow__no">3</span><h3 class="steps-flow__title">서명</h3><p class="steps-flow__desc">…</p></li>
  <li class="steps-flow__item"><span class="steps-flow__no">4</span><h3 class="steps-flow__title">검토</h3><p class="steps-flow__desc">…</p></li>
</ol>

사용 사례 가로 띠

쓰는 곳마다 다른 쓰임을 보여 줄 때 써요. 768px 부터 세로로 내리면 띠가 옆으로 밀리고, 좁은 화면은 손가락으로 넘기는 띠와 진행 점이에요.

.hscroll.usecases흰 바탕 · 구역 높이 --hscroll-h(화면 높이의 2.6배)예시 문구
쓰는 곳

연구실마다 다른 방식에그대로 맞춰요

대학 연구실
졸업해도 기록은 연구실에 남아요

학생마다 따로 쓰던 노트를 과제별로 모아요. 졸업한 뒤에도 책임교수가 그대로 이어 봐요.

연구실 이름으로 가입하면 학생 계정을 한 번에 초대해요.

기업 부설 연구소
특허 출원 전 기록을 지켜요

발명한 날과 사람이 서명과 시각으로 남아요. 출원팀에는 필요한 노트만 열어 줘요.

외부 변리사는 기간을 정해 읽기만 하게 초대해요.

병원·임상 연구
승인된 양식으로만 적어요

연구계획서에 맞춘 양식을 책임자가 정해 두면 모두 같은 칸을 채워요.

양식을 바꿔도 이전 노트는 옛 양식 그대로 남아요.

국가 연구과제
보고서에 붙일 기록을 바로 뽑아요

과제 기간과 참여 연구원을 고르면 서명된 노트를 한 파일로 묶어요.

감사 기록도 같은 파일 끝에 붙어요.

스타트업 연구팀
작게 시작해 팀이 크면 늘려요

혼자 쓰다가 연구원이 늘면 과제와 권한은 그대로 두고 요금제만 바꿔요.

요금제를 바꾸는 동안에도 노트와 서명은 그대로예요.

넓은 화면에서는 이 판을 지나는 동안 세로 스크롤이 띠를 밀어요. 모션 축소면 붙지 않고 손으로 넘기는 띠가 돼요.

ERB
<%# 768px 부터 세로 스크롤이 띠를 민다(구역 높이 --hscroll-h). 좁은 화면은 넘기는 띠 + 진행 점(fx.js 가 붙인다) %>
<section class="surface-base" id="usecases">
  <div class="hscroll usecases">
    <div class="hscroll__pin">
      <div class="usecases__inner">
        <div class="container container--wide">
          <div class="section-head" data-reveal>
            <span class="eyebrow">쓰는 곳</span>
            <h2 class="t-display-sm ink"><span class="title-line">연구실마다 다른 방식에</span><span class="title-line">그대로 맞춰요</span></h2>
          </div>
        </div>
        <div class="hscroll__track usecases__track">
          <article class="usecase hscroll__card">
            <div class="usecase__top"><span class="usecase__icon"><%= icon "brain" %></span><%= ui_badge "대학 연구실", "neutral", pill: true %></div>
            <h3 class="t-heading-md ink">졸업해도 기록은 연구실에 남아요</h3>
            <p class="t-body-sm ink-muted">학생마다 따로 쓰던 노트를 과제별로 모아요.</p>
            <div class="usecase__visual mock mock--flat" aria-hidden="true">
              <div class="mock__row">…</div>
            </div>
            <p class="usecase__note">연구실 이름으로 가입하면 학생 계정을 한 번에 초대해요.</p>
          </article>
          …
        </div>
      </div>
    </div>
  </div>
</section>

쌓이는 기둥 카드

서비스의 큰 약속 셋을 하나씩 강조할 때 써요. 내리면 다음 카드가 위로 겹치고, 덮인 카드는 조금 작아지고 어두워져요.

.stackcards > .pillar들어간 면 · pillar--accent · --good · --note예시 문구
바쁜 날에도

실험이 몰리는 날에도기록은 새지 않아요

01
쓰는 동안 저장돼요

한 줄 적을 때마다 저장해요. 창을 닫아도 마지막 문장까지 남아 있어요.

  • 인터넷이 끊기면 다시 연결될 때 이어서 저장해요
  • 같은 노트를 여러 명이 열면 누가 쓰는지 보여요
02
서명한 노트는 잠겨요

작성자와 검토자가 서명하면 그 시각으로 노트가 잠겨요. 누구도 조용히 고칠 수 없어요.

  • 서명마다 이름과 시각, 버전이 함께 남아요
  • 고칠 게 생기면 새 버전으로 이어 써요
03
바뀐 곳은 모두 남아요

누가 언제 무엇을 바꿨는지 버전마다 남아요. 감사 때는 기간만 골라 내려받아요.

  • 두 버전을 나란히 놓고 바뀐 줄만 봐요
  • 노트를 열어 본 기록도 함께 남아요

카드 배경은 불투명해야 뒤 카드가 비치지 않아요. 화면보다 긴 카드는 붙지 않으니 모바일에서는 목업을 짧게 둬요.

ERB
<%# 카드 배경은 불투명해야 한다(pillar--accent·--good·--note 는 연한 역할색을 면 위에 겹친다) %>
<div class="stackcards section-body">
  <div class="stackcards__item pillar pillar--accent">
    <div class="pillar__copy">
      <span class="step-no">01</span>
      <h3 class="t-heading-xl ink">쓰는 동안 저장돼요</h3>
      <p class="t-body-md ink-muted">한 줄 적을 때마다 저장해요. 창을 닫아도 마지막 문장까지 남아 있어요.</p>
      <ul class="checks t-body-sm">
        <li><%= icon "check", size: "sm" %><span>인터넷이 끊기면 다시 연결될 때 이어서 저장해요</span></li>
      </ul>
    </div>
    <div class="pillar__visual" aria-hidden="true">
      <div class="mock mock--flat">
        <div class="mock__row"><span class="mock__time">14:02</span><span class="mock__main"><span class="mock__name">결과 해석을 고쳤어요</span><span class="mock__sub">자동 저장 · 버전 3</span></span><span class="mock__tag mock__tag--good">저장됨</span></div>
      </div>
    </div>
  </div>
  <div class="stackcards__item pillar pillar--good">…</div>
  <div class="stackcards__item pillar pillar--note">…</div>
</div>

숫자 띠

근거가 있는 숫자가 주인공일 때만 써요. 들어올 때 0부터 한 번 세고, 처음 글자에 최종 값을 적어 둬요.

.stats-band · data-count반전면 · 빛 두 덩어리 · 좁은 화면 2열, 768px 4열예시 문구
숫자로 보면

먼저 쓴 연구실에서이만큼 달라졌어요

줄어든 기록 시간
38%
검토 요청에서 서명까지
1.5일
함께 쓰는 연구실
120곳
서명을 마친 노트
4.2만 장

모든 숫자는 화면 예시예요. 실제 지표가 생기면 기준 시점과 범위를 이 줄에 함께 적어요.

소수는 data-count-decimals, 단위는 data-count-suffix. 검증하지 않은 숫자는 쓰지 않아요.

ERB
<section class="section section--lg surface-invert glow-host">
  <div class="glow glow--tl" aria-hidden="true"></div>
  <div class="glow glow--br glow--note" aria-hidden="true"></div>
  <div class="container container--wide">
    <div class="section-head" data-reveal>
      <span class="eyebrow">숫자로 보면</span>
      <h2 class="t-display-sm"><span class="title-line">먼저 쓴 연구실에서</span><span class="title-line">이만큼 달라졌어요</span></h2>
    </div>
    <%# 처음 글자에 최종 값을 적는다. JS 가 없거나 모션 축소여도 숫자가 맞다 %>
    <dl class="stats-band section-body--lg" data-reveal>
      <div class="stats-band__item">
        <dt class="stats-band__label">줄어든 기록 시간</dt>
        <dd class="stats-band__value t-display-md count" data-count="38" data-count-suffix="%">38%</dd>
      </div>
      <div class="stats-band__item">
        <dt class="stats-band__label">검토 요청에서 서명까지</dt>
        <dd class="stats-band__value t-display-md count" data-count="1.5" data-count-decimals="1" data-count-suffix="일">1.5일</dd>
      </div>
      …
    </dl>
    <p class="t-caption ink-subtle section-body">2026년 8월, 3개월 넘게 쓴 연구실을 기준으로 셌어요.</p>
  </div>
</section>

고객 이야기 · 여럿

여러 사람의 짧은 말을 넘겨 보게 할 때 써요. 화살표 버튼, 점, 손가락 밀기, 좌우 화살표 키로 옮겨요.

.testimonials > .quotes[data-quotes]흰 바탕 · 가상의 연구원과 연구소예시 문구
고객 이야기

먼저 써 본 연구원들이이렇게 말해요

“실험대 옆에서 태블릿으로 바로 적으니, 퇴근 전에 노트를 다시 옮겨 적는 일이 없어졌어요.”
김지수 연구원한빛바이오 연구소 · 세포치료제팀
“검토를 부탁받으면 알림을 눌러 바로 열어요. 도장을 받으러 자리를 비우던 시간이 가장 먼저 사라졌어요.”
박도윤 책임연구원누리소재 연구실 · 연구원 9명
“감사 자료를 준비할 때 기간만 고르면 서명과 버전이 담긴 파일이 나와요. 일주일 걸리던 일이 하루면 끝나요.”
오세린 선임연구원아라식품 연구소 · 품질연구팀
ERB
<div class="testimonials section-body" data-reveal>
  <div class="quotes" data-quotes tabindex="0" aria-roledescription="캐러셀" aria-label="고객 이야기">
    <button class="quotes__nav quotes__nav--prev" type="button" data-quotes-prev aria-label="이전 이야기"><%= icon "chevron-left" %></button>
    <button class="quotes__nav quotes__nav--next" type="button" data-quotes-next aria-label="다음 이야기"><%= icon "chevron-right" %></button>
    <div class="quotes__track"><div class="quotes__rail">
      <figure class="quotes__item">
        <span class="quotes__face avatar--text avatar--accent" aria-hidden="true">김</span>
        <blockquote class="quotes__text">“실험대 옆에서 바로 적으니 노트를 다시 옮겨 적는 일이 없어졌어요.”</blockquote>
        <figcaption class="quotes__by"><strong>김지수 연구원</strong>한빛바이오 연구소 · 세포치료제팀</figcaption>
      </figure>
      …
    </div></div>
    <%# 점은 ds.js 가 이야기 수만큼 만든다 %>
    <div class="quotes__dots" role="group" aria-label="이야기 고르기"></div>
  </div>
</div>

고객 이야기 · 한 사람

한 사람의 이야기를 결과 숫자와 함께 크게 보여 줄 때 써요. 요금·도입 사례 같은 하위 페이지에 잘 맞아요.

.testimonial들어간 면 · 인용은 .lit · 1024px 부터 두 칸예시 문구
종이 노트를 스캔해 올리던 일이 없어졌어요. 검토를 부탁하면 그날 안에 서명이 돌아와요.
정하은 책임연구원다온화학 분석실 · 연구원 14명 · 연구실 요금제
노트 한 장 쓰는 시간
40분에서 15분
검토 서명까지
3일에서 반나절
처음 쓰기까지
20분

사람과 숫자는 화면 예시예요.

ERB
<div class="testimonial">
  <figure class="testimonial__quote" data-reveal>
    <span class="testimonial__mark" aria-hidden="true">“</span>
    <blockquote class="testimonial__text t-heading-lg lit">종이 노트를 스캔해 올리던 일이 없어졌어요. 검토를 부탁하면 그날 안에 서명이 돌아와요.</blockquote>
    <figcaption class="testimonial__by">
      <span class="avatar avatar--lg avatar--text avatar--good" aria-hidden="true">정</span>
      <span><span class="testimonial__name">정하은 책임연구원</span><span class="testimonial__role">다온화학 분석실 · 연구원 14명</span></span>
    </figcaption>
  </figure>
  <dl class="testimonial__facts" data-reveal>
    <div class="testimonial__fact"><dt>노트 한 장 쓰는 시간</dt><dd>40분에서 15분</dd></div>
    <div class="testimonial__fact"><dt>검토 서명까지</dt><dd>3일에서 반나절</dd></div>
  </dl>
</div>

요금제

요금제를 고르게 할 때 써요. 월/연을 바꾸면 새 가격만 짧게 올라오고, 주버튼은 추천 요금제 하나에만 둬요.

.billing + .plans > .plan--hot들어간 면 · 900px 부터 3열예시 문구
요금제

혼자 쓰면 무료,연구실이 커지면 바꿔요

모든 유료 요금제는 14일 동안 무료로 써 보고 정해요.

개인

혼자 기록하는 연구자

무료

카드 등록 없이 시작해요

  • 과제 1개 · 저장 공간 2GB
  • 작성자 서명과 버전 기록
  • 노트 양식 기본 5종
연구실
가장 많이 골라요

연구원 2명부터 20명까지

49,000원 49,000 39,200 원 / 월

연 결제로 바꾸면 매달 9,800원 아껴요1년 470,400원을 한 번에 결제해요

  • 개인의 모든 기능
  • 과제 무제한 · 연구원 20명
  • 검토자 서명과 반려
  • 감사 기록 내보내기
기관

여러 연구실이 함께 쓰는 기관

129,000원 129,000 103,200 원 / 월

연 결제로 바꾸면 매달 25,800원 아껴요1년 1,238,400원을 한 번에 결제해요

  • 연구실의 모든 기능
  • 연구실·인원 무제한
  • 기관 로그인 연결
  • 전담 매니저
연구소가 10곳이 넘는 기관이라면보안 심사 자료와 맞춤 계약을 안내해 드려요.
도입 상담 받기

표시한 가격은 부가세 별도이고, 모든 가격은 화면 예시예요.

지금 보는 가격: 월 결제. 바꿀 때마다 묶음에서 ds:billing 이벤트가 올라와요(값 monthly). 버튼 사이는 좌우 화살표로 옮겨요.

ERB
<%# 묶음(data-billing)의 값에 따라 [data-billing-monthly]·[data-billing-annual] 중 하나만 보인다. 스크립트로 바꿀 때는 dsBilling(묶음, "annual") %>
<section class="section section--lg surface-alt on-alt" data-billing="monthly">
  <div class="container container--wide">
    <div class="section-head section-head--center">
      <h2 class="t-display-sm ink">혼자 쓰면 무료, 연구실이 커지면 바꿔요</h2>
      <div class="billing section-body--xs" role="group" aria-label="결제 주기">
        <button class="billing__btn" type="button" data-billing-value="monthly" aria-pressed="true">월 결제</button>
        <button class="billing__btn" type="button" data-billing-value="annual" aria-pressed="false">연 결제<span class="billing__save">20% 할인</span></button>
      </div>
    </div>
    <div class="plans section-body" data-reveal data-dock-hide>
      <article class="plan plan--hot">
        <div class="plan__head"><h3 class="plan__name">연구실</h3><%= ui_badge "가장 많이 골라요", "accent", pill: true %></div>
        <p class="plan__for">연구원 2명부터 20명까지</p>
        <p class="plan__price">
          <span class="plan__was" data-billing-annual><%= krw(49000) %></span>
          <span class="plan__amount t-display-sm" data-billing-monthly>49,000</span>
          <span class="plan__amount t-display-sm" data-billing-annual>39,200</span>
          <span class="plan__unit">원 / 월</span>
        </p>
        <p class="plan__note"><span data-billing-monthly>연 결제로 바꾸면 매달 9,800원 아껴요</span><span data-billing-annual>1년 470,400원을 한 번에 결제해요</span></p>
        <ul class="checks plan__list">
          <li><%= icon "check", size: "sm" %><span>과제 무제한 · 연구원 20명</span></li>
        </ul>
        <%# 주버튼은 추천 요금제(plan--hot) 하나. 나머지는 btn--secondary %>
        <div class="plan__cta"><%= ui_button "14일 무료로 써 보기", variant: "primary", size: "lg", href: "/signup" %></div>
      </article>
      …
      <div class="plans__more">
        <div class="plans__more-text"><strong class="t-heading-sm ink">연구소가 10곳이 넘는 기관이라면</strong><span class="t-body-sm ink-muted">맞춤 계약을 안내해 드려요.</span></div>
        <%= ui_button "도입 상담 받기", size: "lg", href: "/contact" %>
      </div>
    </div>
  </div>
</section>

비교표

요금제별 차이를 자세히 볼 때 써요. 좁은 화면에서는 표만 옆으로 밀리고 첫 열이 붙어 있어요. 위 요금제와 같은 묶음이라 머리 가격도 함께 바뀌어요.

.compare흰 바탕 · 강조 열 col.compare__hot · 있음·없음은 아이콘과 글자로예시 문구
자세히 비교

요금제별로 무엇이 다른지 한 표로 봐요

요금제별 기능 비교
기능 개인무료 연구실월 49,000원월 39,200원 기관월 129,000원월 103,200원
기록
과제1개무제한무제한
함께 쓰는 연구원1명20명무제한
저장 공간2GB연구원당 20GB무제한
노트 양식기본 5종직접 만들기직접 만들기
서명·검토
작성자 서명있음있음있음
검토자 서명과 반려없음있음있음
외부 검토자 초대없음있음있음
보안·감사
버전 기록과 비교있음있음있음
감사 기록 내보내기없음있음있음
기관 로그인 연결없음없음있음
도움
채팅 상담있음있음있음
전담 매니저없음없음있음

표를 옆으로 밀어 모든 요금제를 볼 수 있어요

ERB
<%# 좁은 화면에서는 표만 옆으로 밀린다. 밀 수 있는 영역이라 tabindex 와 이름을 준다. 요금 페이지 전체를 data-billing 으로 묶으면 머리 가격도 바뀐다 %>
<div class="compare section-body" tabindex="0" role="region" aria-label="요금제 비교표">
  <table class="compare__table">
    <caption class="sr-only">요금제별 기능 비교</caption>
    <colgroup><col><col><col class="compare__hot"><col></colgroup>
    <thead>
      <tr>
        <th scope="col">기능</th>
        <th scope="col">개인<small>무료</small></th>
        <th scope="col" class="compare__hot">연구실<small><span data-billing-monthly>월 49,000원</span><span data-billing-annual>월 39,200원</span></small></th>
        <th scope="col">기관<small>…</small></th>
      </tr>
    </thead>
    <tbody>
      <tr class="compare__group"><th scope="colgroup" colspan="4"><span>서명·검토</span></th></tr>
      <tr>
        <th scope="row">검토자 서명과 반려</th>
        <td><%= icon "minus", size: "sm", class: "compare__no" %><span class="sr-only">없음</span></td>
        <td><%= icon "check", size: "sm", class: "compare__yes" %><span class="sr-only">있음</span></td>
        <td><%= icon "check", size: "sm", class: "compare__yes" %><span class="sr-only">있음</span></td>
      </tr>
    </tbody>
  </table>
</div>
<p class="compare__hint"><%= icon "arrow-right", size: "sm" %>표를 옆으로 밀어 모든 요금제를 볼 수 있어요</p>

신뢰

보안·백업처럼 믿고 맡길 이유를 모아 보여 줄 때 써요. 반전면에 두고 아이콘 칸은 면 색을 투명하게 섞어요.

.trust반전면 · 1열 → 640px 2열 → 1024px 3열예시 문구
안심하고 맡기도록

연구 기록과 서명,이렇게 지켜요

연구실의 가장 중요한 자산은 실험 기록이에요. 그래서 가장 먼저 챙겨요.

  • 보낼 때도, 둘 때도 암호화

    노트와 첨부 파일은 오갈 때와 저장할 때 모두 암호화해요.

  • 매일 백업, 30일 보관

    하루 한 번 다른 지역에 백업하고 30일 안의 어느 날로든 되살려요.

  • 서명 뒤에는 잠금

    서명한 노트는 누구도 고칠 수 없어요. 고치면 새 버전이 생겨요.

  • 모든 변경을 기록

    누가 언제 노트를 열고 바꿨는지 감사 기록으로 남아요.

  • 과제별 접근 권한

    과제 책임자가 허락한 사람만 노트를 열 수 있어요.

  • 언제든 전체 내보내기

    모든 노트를 PDF와 원본 파일로 한 번에 내려받아요.

보안·백업 약속은 화면 예시예요. 실제 정책을 정한 뒤 문구를 바꿔요.

ERB
<section class="section section--lg surface-invert glow-host">
  <div class="glow glow--tr" aria-hidden="true"></div>
  <div class="glow glow--bl glow--note" aria-hidden="true"></div>
  <div class="container container--wide">
    <div class="section-head" data-reveal>
      <span class="eyebrow">안심하고 맡기도록</span>
      <h2 class="t-display-sm"><span class="title-line">연구 기록과 서명,</span><span class="title-line">이렇게 지켜요</span></h2>
    </div>
    <ul class="trust section-body--lg" data-reveal data-reveal-group>
      <li class="trust__item">
        <span class="trust__icon"><%= icon "lock" %></span>
        <h3 class="trust__title">보낼 때도, 둘 때도 암호화</h3>
        <p class="trust__desc">노트와 첨부 파일은 오갈 때와 저장할 때 모두 암호화해요.</p>
      </li>
      …
    </ul>
  </div>
</section>

자주 묻는 질문

가입을 망설이게 하는 질문을 먼저 풀어 줄 때 써요. 1024px 부터 왼쪽 머리가 붙어 따라오고, 오른쪽 답은 하나씩 열려요.

.faq + [data-accordion-single]흰 바탕 · 옆 상자 faq__more 로 상담 길예시 문구
자주 묻는 질문

도입 전에많이 물어봐요

답을 못 찾으셨나요?평일 오전 10시부터 오후 6시까지 채팅으로 답해요. 채팅 상담

기관마다 기준이 달라요. 연구대장은 작성자·검토자 서명과 변경 기록을 남기고, 도입 전에 기관 기준에 맞는지 함께 확인해 드려요.

한글·워드·PDF 파일과 사진을 끌어다 놓으면 노트로 옮겨요. 날짜와 작성자는 파일에서 읽어 채우고, 틀린 곳만 고치면 돼요.

서명한 노트는 잠겨 있어요. 새 버전을 만들어 고치고, 두 버전을 나란히 놓고 바뀐 줄을 볼 수 있어요.

과제 책임자가 허락한 사람만 봐요. 외부 검토자는 기간을 정해 읽기만 할 수 있게 초대해요.

해지한 뒤 90일 동안 모든 노트를 PDF와 원본 파일로 내려받을 수 있어요. 그 뒤에는 모두 지워요.

ERB
<section class="section section--lg surface-base" id="faq">
  <div class="container container--wide">
    <div class="faq">
      <div class="faq__side">
        <div class="section-head" data-reveal>
          <span class="eyebrow">자주 묻는 질문</span>
          <h2 class="t-display-sm ink"><span class="title-line">도입 전에</span><span class="title-line">많이 물어봐요</span></h2>
        </div>
        <div class="faq__more" data-reveal>
          <span class="faq__more-text"><strong>답을 못 찾으셨나요?</strong>평일 오전 10시부터 오후 6시까지 채팅으로 답해요.</span>
          <%= ui_button "채팅 상담", href: "/contact" %>
        </div>
      </div>
      <div data-accordion data-accordion-single data-reveal>
        <div class="accordion__item">
          <button class="accordion__trigger" type="button" aria-expanded="false">
            <span>서명한 노트를 고쳐야 하면요?</span><%= icon "chevron-down", class: "accordion__chev" %>
          </button>
          <div class="accordion__panel"><div class="accordion__body"><p>서명한 노트는 잠겨 있어요. 새 버전을 만들어 고쳐요.</p></div></div>
        </div>
        …
      </div>
    </div>
  </div>
</section>

마지막 CTA

페이지 끝에서 마지막으로 한 번 권할 때 써요. 내리는 만큼 가운데서 가장자리로 펼쳐지고, 반전이 부담스러운 화면은 연한 판을 써요.

.cta-final흰 바탕 위 반전 판 · data-scrub="clip"예시 문구

가입 3분 · 카드 등록 없음

다음 실험 기록은연구대장에서 써요

과제 이름만 정하면 바로 첫 노트를 쓸 수 있어요. 혼자 쓰면 계속 무료예요.

14일 동안 연구실 요금제를 그대로 써 볼 수 있어요

다크 모드에서는 반전 판이 밝은 판이 돼요. 페이지 끝의 마침표 역할은 그대로예요.

ERB
<%# 앞 섹션과 같은 흰 바탕이면 section--join 으로 위 여백을 뺀다. 연한 판은 cta-final--soft %>
<section class="section section--lg section--join surface-base" id="start">
  <div class="container container--wide">
    <div class="cta-final" data-scrub="clip" data-dock-hide>
      <div class="glow glow--tl" aria-hidden="true"></div>
      <div class="glow glow--br glow--note" aria-hidden="true"></div>
      <div class="dotfield" aria-hidden="true"></div>
      <p class="cta-final__eyebrow">가입 3분 · 카드 등록 없음</p>
      <h2 class="cta-final__title t-display-md"><span class="title-line">다음 실험 기록은</span><span class="title-line">연구대장에서 써요</span></h2>
      <p class="cta-final__lede">과제 이름만 정하면 바로 첫 노트를 쓸 수 있어요.</p>
      <div class="cta-final__actions btn-row btn-row--fill">
        <%= ui_button "무료로 시작하기", variant: "primary", size: "lg", href: "/signup", icon_name: "arrow-right", icon_after: true %>
        <%= ui_button "도입 상담 받기", size: "lg", href: "/contact" %>
      </div>
      <p class="cta-final__note">14일 동안 연구실 요금제를 그대로 써 볼 수 있어요</p>
    </div>
  </div>
</section>

체크 목록 세 가지

요금제 목록은 기본, 덜 중요한 목록은 흐린 체크, 한 줄씩 힘을 줄 때는 카드 모양을 써요.

.checks
  • 작성자 서명과 버전 기록
  • 검토자 서명과 반려
.checks--muted
  • 노트 양식 기본 5종
  • 채팅 상담
.checks--cards
  • 서명하면 노트가 잠기고 시각이 찍혀요
  • 누가 언제 무엇을 바꿨는지 남아요
ERB
<%# 기본은 초록 체크. --muted 흐린 체크, --cards 줄마다 흰 카드 + 아이콘 칸, --invert 반전면 위 %>
<ul class="checks t-body-sm">
  <li><%= icon "check", size: "sm" %><span>작성자 서명과 버전 기록</span></li>
</ul>
<ul class="checks checks--muted t-body-sm">
  <li><%= icon "check", size: "sm" %><span>노트 양식 기본 5종</span></li>
</ul>
<ul class="checks checks--cards t-body-sm">
  <li><%= icon "shield" %><span>서명하면 노트가 잠기고 시각이 찍혀요</span></li>
</ul>

섹션을 짜는 조각

눈썹 글자, 단계 번호, 줄마다 나눈 제목, 좁은 화면에서 꽉 차는 버튼 줄이에요. 인라인 style 대신 이 조각으로 간격을 맞춰요.

.eyebrow검토와 서명
.step-no01
.title-line (줄마다 따로 균형을 잡아요)

검토 요청부터 서명까지,자리에서 끝내요

.btn-row--fill (480px 미만은 꽉 채워 쌓아요)
ERB
<span class="eyebrow">검토와 서명</span>
<span class="step-no">01</span>
<%# <br> 대신 줄마다 블록으로 둔다. 줄마다 따로 균형을 잡는다 %>
<h2 class="t-display-sm ink"><span class="title-line">검토 요청부터 서명까지,</span><span class="title-line">자리에서 끝내요</span></h2>
<%# 480px 미만에서 버튼을 가로로 꽉 채워 쌓는다(글자 두 줄 꺾임 방지). 가운데는 btn-row--center %>
<div class="btn-row btn-row--fill">
  <%= ui_button "무료로 시작하기", variant: "primary", size: "lg", href: "/signup" %>
  <%= ui_button "도입 상담 받기", size: "lg", href: "/contact" %>
</div>
<%# 섹션 머리와 본문 사이는 section-body(--xs --sm --lg), 같은 면이 이어지면 section--join %>
<div class="features section-body">…</div>

면의 리듬

랜딩 한 장을 띠로 줄였어요. 흰 바탕과 들어간 면을 번갈아 두고, 반전면은 한두 번만 써요. '피할 순서'를 누르면 틀린 예가 보여요.

  1. 01 히어로 큰 연출 흰 바탕
  2. 02 로고 띠 흰 바탕
  3. 03 문제 제시 들어간 면
  4. 04 사용 사례 가로 띠 큰 연출 흰 바탕
  5. 05 기능 격자 들어간 면
  6. 06 쌓이는 기둥 카드 큰 연출 흰 바탕
  7. 07 숫자 띠 반전면
  8. 08 고객 이야기 흰 바탕
  9. 09 요금제 들어간 면
  10. 10 자주 묻는 질문 흰 바탕
  11. 11 마지막 CTA(이어 붙임) 흰 바탕
  12. 12 푸터 들어간 면

재생 표시는 스크롤을 붙잡는 큰 연출(가로 띠·쌓이는 카드·스크롤 장면)이에요.

쌓을 때 지킬 것

블록을 고른 다음 이 여섯 가지만 확인하면 페이지 한 장이 고르게 읽혀요.

규칙이유
흰 바탕과 들어간 면을 번갈아 둬요선이나 카드 없이도 구획이 나뉘어요
같은 면이 이어지면 section--join위 여백을 빼서 한 덩어리로 읽혀요
반전면은 한두 번, 연달아 두지 않아요숫자·신뢰·마지막 CTA에서만 강조가 살아요
큰 연출 사이에 조용한 섹션을 끼워요스크롤을 붙잡는 구역이 이어지면 지쳐요
주버튼은 첫 화면·추천 요금제·마지막 CTA에만한 화면에 주버튼 둘이 보이지 않아요
제목은 첫 화면 t-display-xl, 섹션 t-display-sm마지막 CTA는 t-display-md. 크기로 페이지의 무게가 나뉘어요

폭마다 바뀌는 모양

같은 마크업이 폭에 따라 모양을 바꿔요. 모바일용 마크업을 따로 만들지 않아요.

블록~639px640~1023px1024px~
히어로 무대폰 + 조각 둘이 가장자리에 걸쳐요900px 부터 조각 넷같음
두 칸 히어로글 아래에 폰같음왼쪽 글 · 오른쪽 폰
기능 격자한 열두 열세 열(features--4 는 네 열)
두 칸 · 질문머리 위, 목록 아래같음왼쪽 머리가 붙어 따라와요
단계 흐름세로(왼쪽 선)768px 부터 가로(위쪽 선)같음
사용 사례넘기는 띠 + 진행 점768px 부터 세로 스크롤이 밀어요같음
숫자 띠두 열768px 부터 네 열같음
요금제한 열900px 부터 세 열같음
비교표표만 옆으로 밀려요 + 안내 줄768px 부터 안내 줄이 숨어요전체가 보여요
신뢰한 열두 열세 열

표를 옆으로 밀어 모든 폭을 볼 수 있어요

16연출

움직임은 읽는 속도에 맞춰요

소개 화면에서 스크롤에 맞춰 나타나고 붙고 쌓이는 연출이에요. 모바일에서는 끄지 않고 모양을 바꾸고, 모션 축소 설정이면 완성된 모습만 남겨요.

스크롤 리빌 · data-reveal

화면에 들어올 때 한 번 올라와요. 목록 항목은 sm, 카드·문단은 기본, 섹션 덩어리는 lg, 첫 화면은 xl, 목업은 scale.

data-reveal="값" · 올라오는 거리
sm 12px 목록 항목·배지
기본 20px 카드·문단
lg 40px 섹션 덩어리
xl 64px 첫 화면
scale 24px·확대 목업·큰 그림
data-reveal-group · 자식이 70ms 간격으로
PCR 조건 비교김지수 · 9/28(월)서명함
세포 배양 3일차이하은 · 9/27(일)검토 중
시약 재고 점검박도윤 · 9/26(토)작성 중

모션 축소 설정이면 처음부터 보여서 다시 보기에도 움직이지 않아요.

ERB
<%# 들어올 때 한 번 올라온다. 값: sm 12px · (기본) 20px · lg 40px · xl 64px · scale 24px + 살짝 확대 %>
<section class="section" data-reveal="lg">…</section>
<article class="card" data-reveal>…</article>

<%# 묶음은 제자리에 두고 자식이 70ms 간격으로 차례로 올라온다 %>
<div class="list list--inset-divider" data-reveal="sm" data-reveal-group>
  <%= row_item(title: "PCR 증폭 조건 비교", sub: "김지수 연구원 · 9/28(월) 14:02", lead: icon("doc"), tail: ui_badge("서명함", "green")) %>
  <%= row_item(title: "세포 배양 3일차 관찰", sub: "이하은 연구원 · 9/27(일) 18:40", lead: icon("image")) %>
</div>

<%# 다시 재생(데모): is-done 인 채 is-in 을 떼어 시작 자리로 돌린 뒤, 다음 프레임에 is-done 을 떼고 is-in 을 붙인다 %>
<%# 모션 축소·JS 없음이면 처음부터 보인다(내용이 사라지지 않는다) %>

스크롤 장면 · .scene

설명이 지나가는 동안 붙어 있는 화면이 바뀌어요. 1000px 부터는 왼쪽 설명과 오른쪽 폰, 좁은 화면은 폰이 위에 붙고 흐린 유리 카드가 그 위로 지나가요.

01

실험을 기록해요

과제와 날짜, 실험 조건을 적으면 노트 한 장이 돼요. 쓰는 동안 임시로 저장돼요.

02

파일을 붙여요

젤 사진과 측정 원본을 노트에 끌어다 놓아요. 올린 파일은 노트와 함께 보관돼요.

03

서명해요

작성자가 전자서명하면 그 시점의 내용이 잠겨요. 고칠 게 생기면 새 버전으로 남아요.

04

검토가 끝나요

책임연구원이 검토 서명을 하면 노트가 확정돼요. 누가 언제 봤는지는 감사 기록에 남아요.

폰 속 화면은 이용 예시예요. 단계 글은 문서 순서에 그대로 있어 화면 읽기가 차례로 읽어요.

ERB
<%# 1000px 이상: 왼쪽 설명 + 오른쪽 붙는 폰 / 좁은 화면: 폰이 위에 붙고 유리 카드(scene--glass)가 지나간다 %>
<div class="scene scene--roomy scene--glass">
  <div class="scene__grid">
    <div class="scene__steps">
      <div class="scene__step">
        <span class="step-no">01</span>
        <h3 class="t-heading-xl ink">실험을 기록해요</h3>
        <p class="t-body-md ink-muted measure">과제와 날짜, 실험 조건을 적으면 노트 한 장이 돼요.</p>
      </div>
      <%# … 02 파일을 붙여요 · 03 서명해요 · 04 검토가 끝나요 %>
    </div>
    <div class="scene__stage">
      <div class="scene__frame">
        <div class="scene__shot" data-on>                 <%# 1단계 화면(처음부터 보인다) %>
          <div class="phone" aria-hidden="true">…<div class="mock">…</div>…</div>
          <div class="scene__shot-caption" aria-hidden="true">…1단계 글 그대로…</div>
        </div>
        <div class="scene__shot">…</div>                   <%# 2단계 %>
        <div class="scene__shot" data-steps="2-3">         <%# 3~4단계 동안 같은 화면 %>
          <div class="phone" aria-hidden="true">
            …
            <div class="mock__row" data-scene-from="3">검토자 서명</div>   <%# 4단계부터 줄이 쌓인다 %>
          </div>
        </div>
      </div>
      <%# 서버가 첫 단계 글로 미리 그린다(스크립트가 늦어도 문서 길이가 같다) %>
      <div class="scene__caption" aria-hidden="true">…1단계 글 그대로…</div>
    </div>
  </div>
</div>

장면 높이는 --fx-vh 로

휴대폰 주소창이 나타나고 사라져도 장면 길이가 바뀌지 않게, 처음 잰 화면 높이를 px 로 고정해 써요.

지금 이 화면-잰 값이 여기에 보여요

규칙내용
재는 값창 높이와 문서 높이 중 작은 값(도구막대가 보일 때 기준)
다시 재는 때터치 기기는 폭이 바뀔 때(회전)만, 마우스는 창 크기가 바뀔 때마다
740px 미만data-fx-compact 캡션 글자를 줄여 폰 자리를 넓혀요
600px 미만data-fx-short 폰을 줄이지 않고 장면을 차례로 나열해요
쓰지 않는 것장면 높이에 vh·svh·dvh, 높이 미디어 쿼리
CSS
/* 스크롤 장면 높이는 처음 잰 화면 높이(--fx-vh, px)로 잡는다. vh·svh·dvh·높이 미디어 쿼리는 쓰지 않는다.
   --fx-vh 는 marketing 레이아웃 머리말 스크립트와 fx.js 가 같은 규칙으로 잰다(없으면 100svh). */
.hscroll{height:var(--hscroll-h,calc(var(--fx-vh) * 2.4))}
.scene__step{min-height:calc(var(--fx-vh) * .72)}

/* 처음 잰 높이에 따라 html 에 표식이 붙는다(미디어 쿼리가 아니라서 주소창이 움직여도 배치가 튀지 않는다) */
html[data-fx-compact] { /* 740px 미만: 장면 캡션 글자를 줄인다 */ }
html[data-fx-short]   { /* 600px 미만: 폰을 줄이지 않고 장면을 차례로 나열한다 */ }

진행 점 · data-snapdots

가로 스냅 띠 아래에 몇 번째를 보는지 점으로 알려요. 넘칠 때만 보이고, 점을 누르면 그 카드로 넘어가요.

01

실험 기록

날짜·조건·결과를 한 장에

02

첨부 보관

사진과 원본 데이터를 노트에

03

전자서명

작성자와 검토자가 차례로

04

버전 기록

고친 내용은 새 버전으로

05

감사 기록

누가 언제 봤는지 남겨요

ERB
<%# 가로 스냅 띠 뒤에 fx.js 가 진행 점(.snapdots)을 붙인다. 넘칠 때만 보이고, 점을 누르면 그 카드로 넘어간다 %>
<div class="rail" data-snapdots>
  <article class="card">
    <%= ui_badge("01", "accent") %>
    <p class="t-heading-sm ink">실험 기록</p>
    <p class="t-body-sm ink-muted">날짜·조건·결과를 한 장에</p>
  </article>
  <%# … %>
</div>

가로 스크롤 구역 · .hscroll

768px 부터는 세로로 내리는 만큼 띠가 옆으로 밀려요. 좁은 화면은 손가락으로 넘기는 띠에 진행 점이 붙어요.

연구실마다 쓰는 방식 · 예시

한 서비스로 여러 연구실이 기록해요

바이오 실험실

세포 사진은 하루 단위로

배양 일차마다 사진을 붙이고 관찰 내용을 적어요.

분석 화학

측정 원본은 그대로

장비에서 받은 원본 파일을 고치지 않고 보관해요.

소재 연구

시약은 로트 번호까지

어느 로트를 썼는지 노트마다 남겨 재현을 도와요.

소프트웨어 연구

실험 설정은 버전으로

모델 설정과 결과를 버전마다 나란히 비교해요.

임상 준비

검토 서명은 두 번

작성자와 책임연구원이 차례로 서명해요.

ERB
<%# 768px 이상: 세로 스크롤이 가로 띠를 민다(구역 높이 --hscroll-h, 기본 --fx-vh 의 2.4배)
    768px 미만: 손가락으로 넘기는 스냅 띠 + 진행 점(fx.js 가 붙인다) · 모션 축소: 스냅 띠 %>
<div class="hscroll">
  <div class="hscroll__pin">
    <div>
      <div class="container container--wide">
        <h2 class="t-display-sm ink">한 서비스로 여러 연구실이 기록해요</h2>
      </div>
      <div class="hscroll__track">
        <article class="card card--line hscroll__card">
          <span class="feature__icon"><%= icon("image") %></span>
          <p class="t-heading-md ink">세포 사진은 하루 단위로</p>
          <p class="t-body-sm ink-muted">배양 일차마다 사진을 붙이고 관찰 내용을 적어요.</p>
        </article>
        <%# … %>
      </div>
    </div>
  </div>
</div>

쌓이는 카드 · .stackcards

내리면 카드가 위로 겹쳐 쌓이고, 덮이는 만큼 뒤 카드가 작아지고 어두워져요. 카드 배경은 불투명해야 하고, 화면보다 긴 카드는 붙지 않아요.

01

기록은 고칠 수 없게 남아요

서명한 노트는 잠겨요. 고칠 게 생기면 새 버전으로 쌓이고, 앞 버전도 그대로 열어 볼 수 있어요.

  • 버전마다 서명 시각이 남아요
  • 지운 문장도 이전 버전에 남아요
02

누가 언제 봤는지 남아요

열람·수정·서명·내보내기가 모두 감사 기록에 쌓여요. 기록은 관리자도 지울 수 없어요.

  • 외부 감사에 기간별로 내보낼 수 있어요
  • 그만둔 구성원은 바로 접근을 막아요
03

과제별로 한곳에 모여요

과제 번호로 노트·첨부·시약 기록을 묶어요. 과제가 끝나면 한 번에 내보내요.

  • 과제마다 볼 수 있는 사람을 정해요
  • 끝난 과제도 검색에 그대로 나와요

카드 속 기능 설명은 예시 문구예요.

ERB
<%# 다음 카드가 덮는 만큼 뒤 카드가 작아지고 어두워진다(--stack-p). 배경은 불투명해야 한다.
    반투명 역할색은 면 위에 겹쳐 쓴다: background:linear-gradient(var(--good-soft),var(--good-soft)),var(--surface) %>
<div class="stackcards">
  <div class="stackcards__item pillar pillar--accent">
    <div class="pillar__copy">
      <span class="step-no">01</span>
      <h3 class="t-heading-xl ink">기록은 고칠 수 없게 남아요</h3>
      <p class="t-body-md ink-muted">서명한 노트는 잠겨요. 고칠 게 생기면 새 버전으로 쌓여요.</p>
    </div>
    <div class="pillar__visual" aria-hidden="true">…</div>
  </div>
  <div class="stackcards__item pillar pillar--good">…</div>
  <div class="stackcards__item pillar pillar--note">…</div>
</div>

스크럽 · data-scrub

스크롤 진행도에 물려 프레임마다 값이 바뀌어요. 올라오기·확대·일어서기·펼치기·옆으로 흐르기 다섯 가지이고, 모바일에서도 그대로 돌아요.

data-scrub="값"

올라오기

40px 아래서 흐리게

rise

확대

88%에서 100%로

zoom

일어서기

14° 누웠다가 바로

tiltin

펼치기

가운데서 가장자리로

clip
drift · 옆으로 흐르기
ERB
<%# 스크롤 진행도에 물려 프레임마다 바뀐다. rise · zoom · tiltin · clip · drift. 모션 축소면 최종 상태 %>
<div class="stage" data-scrub="tiltin">…폰 목업…</div>
<div class="panel panel--sky" data-scrub="zoom">…</div>
<div class="cta-final" data-scrub="clip">…가운데서 가장자리로 펼쳐지는 면…</div>
<p class="t-display-sm ink" data-scrub="drift" aria-hidden="true">실험 · 기록 · 첨부 · 서명 · 검토</p>

시차 · data-parallax

화면 가운데서 멀수록 더 밀려요. 층마다 세기와 방향을 달리하면 깊이가 생겨요. 음수면 반대 방향이에요.

연구노트 · v3

PCR 증폭 조건 비교

김지수 연구원 · 9/28(월)

첨부 3개
검토 끝남

빛 0.16 · 카드 -0.05 · 조각 0.12 와 -0.14

ERB
<%# 화면 가운데서 멀수록 더 밀린다. 값이 클수록 세고, 음수면 반대 방향. 층마다 다르게 주면 깊이가 생긴다 %>
<div class="stage">
  <div class="stage__glow" data-parallax="0.16" aria-hidden="true"></div>
  <div class="card card--float" data-parallax="-0.05">…노트 카드…</div>
  <div class="stage__chip stage__chip--tl" data-parallax="0.12" aria-hidden="true">…</div>
  <div class="stage__chip stage__chip--br" data-parallax="-0.14" aria-hidden="true">…</div>
</div>

숫자 세기 · data-count

들어올 때 한 번만 0부터 세요. 처음 글자에 최종 값을 적어 두면 스크립트가 없거나 모션 축소여도 숫자가 맞아요.

이번 달 기록한 노트
1,284건
기한 안에 끝난 검토
98.2%
함께 쓰는 연구실 구성원
36명

예시 숫자예요. 실제 화면에는 기준 시점을 한 줄로 밝혀요.

ERB
<%# 처음 글자에 최종 값을 적는다. 들어올 때 fx.js 가 0부터 한 번 센다(되감지 않는다) %>
<dl class="stats-band">
  <div class="stats-band__item">
    <dt class="stats-band__label">이번 달 기록한 노트</dt>
    <dd class="stats-band__value t-display-md count" data-count="1284" data-count-suffix="건">1,284건</dd>
  </div>
  <div class="stats-band__item">
    <dt class="stats-band__label">기한 안에 끝난 검토</dt>
    <dd class="stats-band__value t-display-md count" data-count="98.2" data-count-decimals="1" data-count-suffix="%">98.2%</dd>
  </div>
</dl>
<%# 앞에 붙는 글자는 data-count-prefix. 기준 시점·범위를 아래 한 줄로 밝힌다 %>

흐르는 띠 · .ticker

같은 두 묶음을 이어 붙여 끊김 없이 흘러요. 5초 넘게 움직이니 멈춤 버튼을 둬요(WCAG 2.2.2). 화면 밖·탭 숨김·모션 축소면 저절로 멈춰요.

연구대장으로 기록하는 것
  • 실험 노트
  • 시약 대장
  • 장비 예약
  • 전자서명
  • 검토 요청
  • 버전 기록
  • 첨부 보관
  • 감사 기록
ERB
<%# 같은 두 묶음을 이어 붙여 -50% 에서 끊김 없이 돈다. 두 번째 묶음은 aria-hidden.
    화면 밖·탭 숨김·모션 축소·멈춤 버튼이면 선다(sections.js). 모션 축소면 버튼이 숨는다 %>
<button class="btn btn--ghost btn--sm btn--square ticker-toggle" type="button"
        data-ticker-toggle aria-controls="note-ticker" aria-pressed="false" aria-label="움직임 멈추기">
  <%= icon("pause", size: "sm") %>
</button>
<div class="ticker" id="note-ticker" data-ticker>
  <div class="ticker__track">
    <ul class="logos__group" aria-label="기록하는 것">
      <li class="logos__item"><%= icon("doc") %>실험 노트</li>
      <li class="logos__item"><%= icon("folder") %>시약 대장</li>
    </ul>
    <ul class="logos__group" aria-hidden="true">…같은 목록…</ul>
  </div>
</div>

단어가 차례로 진해져요 · .lit

문단이 화면을 지나는 동안 단어가 하나씩 진해져요. 문제를 던지는 큰 문장 하나에만 쓰고, 안에는 글자만 둬요.

실험은 매일 쌓이는데 기록은 종이 노트와 메신저, 공용 폴더에 흩어져 있지 않나요?

천천히 내려 보세요. 모션 축소면 처음부터 모두 진해요.

ERB
<%# 문단이 화면을 지나는 동안 단어가 차례로 진해진다. 안에는 글자만 둔다(fx.js 가 단어로 쪼갠다) %>
<p class="lit t-display-sm ink">실험은 매일 쌓이는데 기록은 종이 노트와 메신저, 공용 폴더에 흩어져 있지 않나요?</p>
<%# 본문 색으로 끝내려면 lit--muted %>

마우스 빛 · .spotlight

마우스를 따라 옅은 빛이 번져요. 주인공 칸 하나에만 써요. 터치 화면과 모션 축소에서는 빛 없이 그대로예요.

노트 안 글자까지 찾아요

첨부한 PDF 속 문장과 시약 로트 번호도 검색돼요. 마우스를 올려 보세요.

ERB
<%# 마우스를 따라 옅은 빛(--accent-soft)이 번진다. 마우스가 있는 기기에서만, 모션 축소면 없음.
    ::before 를 쓰니 .pressable 과 함께 쓰지 않는다 %>
<article class="tile tile--hero spotlight">
  <span class="tile__icon"><%= icon("search") %></span>
  <h3 class="tile__title">노트 안 글자까지 찾아요</h3>
  <p class="tile__desc">첨부한 PDF 속 문장과 시약 로트 번호도 검색돼요.</p>
</article>

기울기 · data-tilt

마우스를 올린 쪽으로 카드가 살짝 기울어요. 값은 최대 각도(기본 6°)예요. 터치 화면과 모션 축소에서는 기울지 않아요.

연구노트 · v3서명함

PCR 증폭 조건 비교

차세대 바이오센서 개발 · 김지수 연구원

ERB
<%# 올린 쪽으로 기운다. 값은 최대 각도(기본 6). 벗어나면 천천히 제자리. 터치 화면·모션 축소는 기울지 않는다 %>
<div class="card card--float" data-tilt="8">
  <p class="t-heading-sm ink">PCR 증폭 조건 비교</p>
  <p class="t-caption ink-muted">차세대 바이오센서 개발 · 김지수 연구원</p>
</div>

빛 지나감 · .shine

화면에 들어올 때 빛이 한 번 지나가요. 주 동작 버튼이나 첫 화면 제목 한 곳에만 쓰고, 되풀이하지 않아요.

기록은 남고, 서명은 잠겨요

ERB
<%# 화면에 들어올 때 빛이 한 번(data-shine-delay ms 뒤, 기본 240). 한 화면에 한 곳만 %>
<%= ui_button "검토 요청하기", variant: "primary", size: "lg", class: "shine" %>
<p class="t-heading-lg shine--text">기록은 남고, 서명은 잠겨요</p>

<script type="module">
  // 다시 지나가게 할 때
  dsShine(document.querySelector(".shine"))
</script>

축하 입자 · dsCelebrate

큰 첫 성공에 한 번만 조각이 퍼졌다가 1초 안에 사라져요. 모션 축소면 입자 없이 토스트만 남아요.

첫 노트에 서명할 차례예요

서명하면 이 버전의 내용이 잠겨요.

JS
// 요소 가운데(없으면 화면 위쪽 가운데)에서 조각 26개가 퍼졌다가 1초 안에 사라진다. 모션 축소면 아무것도 하지 않는다
// 가입 완료·첫 서명처럼 큰 첫 성공에 한 번만. 결과는 토스트나 결과 화면으로 따로 알린다
await dsCelebrate(button)                 // 끝나면 DOM 을 걷고 약속을 돌려준다
dsCelebrate(null, { count: 30 })          // 20~30개
dsToast("첫 노트에 서명했어요", { tone: "good" })

누르는 느낌 · .pressable

누르면 살짝 줄어들고 옅은 덮개가 깔려요. 스크롤하려고 댄 손가락에는 반응하지 않아요. 긴 행은 --flat(덮개만), 작은 타일은 --deep(95%).

pressable--deep · 타일
pressable--flat · 긴 행 pressable · 고르는 카드(강조 테두리)
ERB
<%# 누르면 98% 로 줄고 덮개가 깔린다. 손가락은 70ms 기다렸다가, 움직이면 붙이지 않는다 %>
<button class="card card--line pressable pressable--deep" type="button">…작은 타일(95%)…</button>
<%= row_item(title: "알림 설정", sub: "검토 요청 · 서명 마감 하루 전", lead: icon("bell"),
             href: settings_path, chev: true, class: "pressable pressable--flat") %>   <%# 긴 행: 덮개만 %>

<%# 고르는 카드: aria-checked="true" 면 강조 테두리 %>
<div role="radiogroup" aria-label="서명 방식">
  <button class="card card--line pressable" type="button" role="radio" aria-checked="true">간편 인증</button>
  <button class="card card--line pressable" type="button" role="radio" aria-checked="false">인증서 파일</button>
</div>
<%# :disabled · aria-disabled="true" 는 누름 없이 흐려진다 %>

화면 전환 · View Transitions

화면이 바뀌는 방향을 보여 줘요. 앞으로 가면 오른쪽에서, 뒤로 가면 왼쪽에서 들어오고, 탭을 옮기면 제자리에서 바뀌어요.

차세대 바이오센서 개발연구노트

폰 속 노트를 누르면 앞으로, 왼쪽 위 화살표는 뒤로, 아래 탭은 제자리 전환이에요.

html[data-nav]움직임 · 언제
forward오른쪽에서 32px 밀려 들어와요. 목록에서 상세로 갈 때(기본)
back왼쪽에서 들어와요. data-nav-back 링크, 브라우저 뒤로
tab제자리에서 바뀌어요. 탭바 안 링크, data-nav-tab
none움직이지 않아요. 폼 제출 뒤 같은 주소, data-nav-none

나가는 화면이 120ms 에 먼저 사라지고 들어오는 화면이 240ms 에 떠올라 글자가 겹치지 않아요. 앱 레이아웃은 켜져 있고, 이 소개 화면은 꺼져 있어 폰 속 화면만 움직여요.

ERB
<%# 앱 레이아웃(layouts/app)은 이미 켜져 있다. Turbo 8 은 이 meta 만 있으면 html[data-transitions] 가 저절로 붙는다 %>
<meta name="view-transition" content="same-origin">

<%# 방향은 누른 링크로 정한다: 그냥 링크 forward · 탭바 안 링크 tab %>
<%= link_to "PCR 증폭 조건 비교", note_path(note) %>
<%= link_to "연구노트", notes_path, data: { nav_back: true } %>   <%# 뒤로: 왼쪽에서 들어온다 %>
<%= link_to "다시 불러오기", notes_path, data: { nav_none: true } %> <%# 움직이지 않는다 %>

<%# 목록과 상세의 같은 요소는 같은 이름으로 이어 붙인다(공유 요소 전환) %>
<div data-vt="note-<%= note.id %>">…</div>

모션 축소와 좁은 화면에서는

좁은 화면에서도 연출을 끄지 않고 모양을 바꿔요. 모션 축소(prefers-reduced-motion: reduce)면 움직임은 없어지고 내용은 전부 남아요.

연출넓은 화면좁은 화면모션 축소
스크롤 리빌들어올 때 올라와요같아요처음부터 보여요
스크롤 장면왼쪽 설명, 오른쪽에 붙은 폰폰이 위에 붙고 유리 카드가 지나가요움직임 없이 화면만 바뀌어요
가로 스크롤세로 스크롤이 띠를 밀어요넘기는 띠 + 진행 점넘기는 띠
쌓이는 카드겹쳐 쌓여요같아요. 화면보다 긴 카드만 붙지 않아요쌓지 않고 나열해요
스크럽·시차진행도에 물려 움직여요같아요최종 모습으로 멈춰요
숫자 세기0부터 한 번 세요같아요최종 숫자
단어 진해짐차례로 진해져요같아요전부 진해요
흐르는 띠흘러요. 멈춤 버튼같아요멈추고 버튼이 숨어요
마우스 빛·기울기마우스를 따라와요터치 화면은 켜지 않아요없어요
빛 지나감·축하 입자한 번 지나가요·퍼져요같아요없어요
누르는 느낌줄어들고 덮개가 깔려요손가락은 70ms 뒤에 보여요줄지 않고 덮개만
화면 전환방향대로 밀려 들어와요같아요바로 바뀌어요
스켈레톤 시머반짝여요같아요멈춰요
결과 그림선이 그려져요같아요다 그려진 상태

17연구노트

기록부터 서명까지, 같은 조각으로

노트 상태·머리·본문·서명·검토·변경 기록을 서비스 화면에서 그대로 가져다 써요. 사람·과제·수치는 모두 예시예요.

노트 상태 체계

노트는 다섯 상태 가운데 하나예요. 목록·머리·서명 블록이 모두 rn_status_badge 하나로 같은 배지를 그려요.

  1. 상태: 작성 중
  2. 상태: 검토 요청
  3. 상태: 서명 완료
  4. 상태: 잠김

검토 요청 중에 상태: 보완 요청 이 오면 작성 중으로 돌아가 고친 뒤 다시 요청해요.

상태뜻다음 행동누가
상태: 작성 중 작성자가 쓰고 있어요. 저장할 때마다 버전이 하나씩 늘어요. 다 쓰면 검토를 요청해요 작성자
상태: 검토 요청 검토자가 읽고 있어요. 그동안 본문은 고칠 수 없어요. 검토자가 서명하거나 보완을 요청해요 검토자
상태: 보완 요청 검토자가 고칠 곳을 남겼어요. 다시 쓸 수 있게 열려요. 고친 뒤 검토를 다시 요청해요 작성자
상태: 서명 완료 작성자와 검토자가 모두 서명했어요. 이 버전은 더 고칠 수 없어요. 고칠 게 생기면 새 버전으로 기록해요 모두
상태: 잠김 보존 기간이라 누구도 고치거나 지울 수 없어요. 열어 본 기록이 남아요. 읽기와 내려받기만 할 수 있어요 관리자

상태마다 아이콘 모양이 달라요. 색을 못 봐도 연필·시계·경고·체크·자물쇠로 구분돼요. 화면 읽기는 '상태: 검토 요청' 으로 읽어요.

ERB
<%# 상태는 다섯 가지: draft 작성 중 · review 검토 요청 · changes 보완 요청 · signed 서명 완료 · locked 잠김 %>
<%= rn_status_badge(note.status) %>                 <%# 아이콘 + 글자, 화면 읽기는 '상태: 검토 요청' %>
<%= rn_status_badge(:signed, size: "lg") %>
<%= rn_status_label(:changes) %>                    <%# "보완 요청" %>
<%= rn_status(:review)[:next] %>                    <%# 다음 행동 한 줄 %>

<%# 표로 모두 그리기 %>
<% rn_statuses.each do |key, s| %>
  <tr><td><%= rn_status_badge(key) %></td><td><%= s[:meaning] %></td><td><%= s[:next] %></td></tr>
<% end %>

노트 카드

홈·과제 첫 화면처럼 몇 개만 크게 보일 때 써요. 카드 전체가 링크 하나예요.

ERB
<%# 카드 전체가 링크 하나. 배지(상태·버전) → 제목 → 과제 → 요약 → 작성자·시각·첨부 %>
<%= rn_note_card(title: note.title, project: note.project.name, author: note.author.name,
                 status: note.status, updated_at: note.updated_at, version: note.version,
                 files: note.attachments.size, excerpt: note.summary, href: note_path(note)) %>

노트 목록 행

노트가 많을 때 한 줄에 하나씩. 제목·과제 아래에 작성자·수정 시각·버전·첨부 수를 가운뎃점으로 이어요.

ERB
<div class="group list list--inset-divider">
  <% @notes.each do |note| %>
    <%= rn_note_row(title: note.title, project: note.project.name, author: note.author.name, tone: "accent",
                    status: note.status, updated_at: note.updated_at, version: note.version,
                    files: note.attachments.size, href: note_path(note)) %>
  <% end %>
</div>
<%# 덧말만 따로: rn_note_meta(author:, updated_at:, version:, files:) → "김지수 · 12분 전 · v3 · 첨부 4" %>

노트 머리

노트 상세 맨 위. 상태 배지 → 제목 → 한 줄 요약 → 기록 정보 순서예요. 해시는 앞 12자리만 보이고 전체를 복사해요.

상태: 작성 중 v3

항체 단편 발현 조건 비교 3차

항체 단편 발현 최적화 · 9/27(일) 실험 · 김지수 연구원

과제
항체 단편 발현 최적화HB-2026-03 · 한빛바이오 연구소
실험 날짜
18°C · 16시간 배양
작성자
김지수 연구원
버전
v342분 전 저장 · 모두 3개
기록 번호
RN-2026-0412
해시
SHA-256290488b64fef
ERB
<div class="rn-head">
  <div class="rn-head__top">
    <header class="detail-head">
      <div class="detail-head__badges"><%= rn_status_badge(@note.status) %><%= ui_badge rn_version(@note.version), "outline" %></div>
      <h1 class="detail-head__title"><%= @note.title %></h1>
      <p class="detail-head__sub"><%= @note.project.name %> · <%= ko_date(@note.done_on) %> 실험 · <%= @note.author.name %></p>
    </header>
    <div class="rn-head__actions">
      <%= ui_button "검토 요청하기", variant: "primary", icon_name: "shield", data: { open: "review-sheet" } %>
      <%= ui_button "기록 번호 복사", icon_name: "copy", data: { copy: @note.number, copy_message: "기록 번호를 복사했어요" } %>
    </div>
  </div>
  <dl class="kv kv--rows rn-head__kv">
    <div class="kv__row"><dt>작성자</dt><dd><span class="rn-person"><%= rn_avatar(@note.author.name, size: "sm") %><%= @note.author.name %></span></dd></div>
    <div class="kv__row"><dt>기록 번호</dt><dd class="tnum"><%= @note.number %></dd></div>
    <div class="kv__row"><dt>해시</dt><dd><%= rn_hash(@note.sha256) %></dd></div>  <%# 앞 12자리 + 전체 복사 %>
  </dl>
</div>

실험 기록 본문

글은 키트 .prose 로 읽기 좋은 줄 길이에, 시약은 .table-dense 로 촘촘하게. 표는 좁은 화면에서 안에서만 옆으로 밀려요.

목적

대장균 BL21(DE3)에서 항체 단편(scFv)이 가장 많이 녹아 나오는 유도 조건을 찾아요.

방법

  1. OD600 이 0.6 일 때 IPTG 를 0.1·0.5·1.0 mM 으로 넣었어요.
  2. 18°C·25°C·37°C 에서 16시간씩 키웠어요.
  3. 원심분리한 뒤 가용성 분획을 SDS-PAGE 로 확인했어요.

결과

18°C · IPTG 0.1 mM 에서 가용성 띠가 가장 진했어요. 37°C 에서는 대부분 봉입체로 갔어요.

다음 실험: 18°C 에서 유도 시간을 8·12·16시간으로 나눠 볼게요.

사용한 시약4종 · 9/27 기준 재고
시약농도양로트 번호
IPTG1 M50 µLLT-2409-118
LB 배지-500 mLMB-0923-07
카나마이신50 mg/mL500 µLKN-2408-33
이미다졸500 mM20 mLIM-2407-02
ERB
<div class="rn-record">
  <div class="prose rn-record__prose">
    <h2 class="t-heading-sm ink">목적</h2>
    <p>대장균 BL21(DE3)에서 항체 단편이 가장 많이 녹아 나오는 유도 조건을 찾아요.</p>
    <h2 class="t-heading-sm ink">방법</h2>
    <ol><li>…</li></ol>
    <blockquote><p>다음 실험: …</p></blockquote>
  </div>
  <figure class="rn-reagents">
    <figcaption class="rn-reagents__cap"><span class="t-body-sm w-600 ink">사용한 시약</span></figcaption>
    <div class="table-scroll">
      <table class="table table-dense rn-reagents__table">
        <thead><tr><th scope="col">시약</th><th scope="col" class="td-num">농도</th><th scope="col" class="td-num">양</th><th scope="col">로트 번호</th></tr></thead>
        <tbody><tr><td class="td-nowrap">IPTG</td><td class="td-num">1 M</td><td class="td-num">50 µL</td><td class="td-mono">LT-2409-118</td></tr></tbody>
      </table>
    </div>
  </figure>
</div>

전자서명 블록

작성자·검토자 서명을 한곳에. 서명은 되돌릴 수 없어서 확인 창으로 한 번 묻고, 끝난 뒤 토스트에 되돌리기가 없어요.

전자서명

서명 2개 중 1개

상태: 검토 요청
  1. 김지수 연구원

    작성자

    서명 완료
  2. 박도윤 책임연구원

    검토자

    서명 대기

서명하면 이 버전의 해시 290488b64fef 를 함께 남기고 본문을 잠가요.

예시라서 다시 해 볼 수 있어요. 실제 화면에는 이 버튼이 없어요.

ERB
<section class="rn-sign" aria-labelledby="sign-title">
  <div class="rn-sign__head">
    <div class="rn-sign__heading">
      <h2 class="t-body-sm w-600 ink" id="sign-title">전자서명</h2>
      <p class="t-caption ink-muted" aria-live="polite">서명 2개 중 1개</p>
    </div>
    <%= rn_status_badge(@note.status) %>
  </div>
  <ol class="rn-sign__list">
    <li class="rn-sign__row" data-state="done">
      <%= rn_avatar("김지수") %>
      <div class="rn-sign__who"><p class="rn-sign__name">김지수 연구원</p><p class="rn-sign__role">작성자</p></div>
      <div class="rn-sign__state">
        <span class="status txt-good"><%= icon("success", size: "sm") %>서명 완료</span>
        <%= tag.time ko_time(sig.signed_at), class: "rn-sign__at", datetime: sig.signed_at.iso8601 %>
      </div>
    </li>
    <li class="rn-sign__row" data-state="wait">
      …
      <div class="rn-sign__state">
        <span class="status txt-warn"><%= icon("clock", size: "sm") %>서명 대기</span>
        <%= ui_button "서명하기", variant: "primary", size: "sm", href: note_signature_path(@note), method: :post,
              confirm: "서명한 뒤에는 고칠 수 없어요. 고칠 게 생기면 새 버전으로 기록해요.", confirm_title: "이 노트에 서명할까요?" %>
      </div>
    </li>
  </ol>
</section>
<%# 서명은 되돌릴 수 없어서 끝난 뒤 토스트에 '되돌리기' 를 붙이지 않는다: dsToast('서명했어요', { tone: 'good' }) %>

검토 요청 시트

검토자를 고르고 메모를 남겨 보내요. 검토자를 안 고르고 보내면 선택기 아래에 이유를 적어요.

휴대폰에서는 아래에서 올라오고, 768px 부터는 가운데 창이 돼요. 보내면 위 '노트 머리' 의 상태가 검토 요청으로 바뀌어요.

  • 선택기는 시트 위 층에 떠요(좁은 화면은 아래 시트)
  • Escape 는 목록만 닫고, 한 번 더 누르면 시트가 닫혀요
  • 닫으면 누른 버튼으로 초점이 돌아와요
ERB
<%# 여는 버튼 %>
<%= ui_button "검토 요청하기", variant: "primary", icon_name: "shield", data: { open: "review-sheet" } %>

<%# 화면 끝(content_for :overlays)에 둔다 %>
<%= ui_sheet "review-sheet", title: "누구에게 검토를 맡길까요?", desc: "보내면 검토자가 답할 때까지 본문을 고칠 수 없어요." do |s| %>
  <% s.body do %>
    <%= ds_form_with model: @review, url: note_reviews_path(@note), id: "review-form" do |f| %>
      <%= f.picker :reviewer_id, @reviewers.map { |r| [r.name, r.id] }, label: "검토자", placeholder: "검토자를 골라 주세요",
            help: "검토자가 서명하면 이 버전이 잠겨요." %>
      <%= f.text_area :memo, label: "메모", optional: true, placeholder: "예: 결과 문단의 농도 단위를 봐 주세요" %>
    <% end %>
  <% end %>
  <% s.foot do %>
    <%= ui_button "닫기", data: { close: "" } %>
    <%= ui_button "보내기", variant: "primary", type: "submit", form: "review-form" %>
  <% end %>
<% end %>
<%# 검토자를 안 고르면 선택기에 aria-invalid="true" + 아래 .field__error '검토자를 골라 주세요…' %>

변경 기록

누가 언제 무엇을 했는지 시간 순서로. 끝난 일은 체크, 지금 기다리는 일은 퍼지는 고리, 남은 일은 빈 원이에요.

  1. 김지수 연구원이 노트를 만들었어요 · 끝남

    v1 · 목적과 방법을 적었어요

  2. 김지수 연구원이 v2를 저장했어요 · 끝남

    결과 문단을 더하고 첨부 2개를 올렸어요

  3. 박도윤 책임연구원이 보완을 요청했어요 · 끝남

    '이미다졸 농도 단위를 mM 으로 맞춰 주세요'

  4. 김지수 연구원이 v3를 저장했어요 · 끝남

    결과 문단 2줄을 고쳤어요

    +3줄−2줄 v2와 비교하기
  5. 박도윤 책임연구원이 검토하고 있어요 · 진행 중

    서명하거나 보완을 요청하면 여기에 쌓여요

  6. 검토자 서명 · 아직

ERB
<ol class="timeline">
  <% @note.events.each do |e| %>
    <li class="timeline__item" data-state="<%= e.state %>">  <%# done · now · todo %>
      <span class="timeline__mark" aria-hidden="true"></span>
      <div class="timeline__body">
        <div class="timeline__head">
          <p class="timeline__title"><%= e.summary %><span class="sr-only"> · 끝남</span></p>
          <%= tag.time ago(e.at), class: "timeline__time", datetime: e.at.iso8601 %>
        </div>
        <p class="timeline__desc"><%= e.detail %></p>
      </div>
    </li>
  <% end %>
</ol>
<%# 누가 · 무엇을: "김지수 연구원이 v3를 저장했어요", 언제: ago(e.at) %>

버전 비교

더한 줄은 + 와 '추가', 뺀 줄은 − 와 '삭제' 글자로 함께 알려요. 색만으로 알리지 않고 바뀐 낱말에는 밑줄과 취소선을 더해요.

v2 → v3

김지수 연구원 · 42분 전

  1. 결과
  2. 삭제이미다졸 500 mmol 로 용출했어요.
  3. 추가이미다졸 500 mM 으로 용출했어요.
  4. 18°C · IPTG 0.1 mM 에서 가용성 띠가 가장 진했어요.
  5. 삭제37°C 조건은 아직 확인하지 못했어요.
  6. 추가37°C 조건은 대부분 봉입체로 갔어요.
  7. 추가다음 실험은 유도 시간을 8·12·16시간으로 나눠 볼게요.
  8. 첨부: gel_0927_western.tif
ERB
<ol class="rn-diff" data-mode="all" aria-label="v2와 v3 비교">
  <li class="rn-diff__line" data-kind="del">            <%# add · del · same %>
    <span class="rn-diff__no" aria-hidden="true">11</span><span class="rn-diff__no" aria-hidden="true"></span>
    <span class="rn-diff__sign" aria-hidden="true">−</span>
    <span class="rn-diff__text"><span class="rn-diff__kind">삭제</span>이미다졸 500 <del class="rn-diff__word">mmol 로</del> 용출했어요.</span>
  </li>
  <li class="rn-diff__line" data-kind="add">
    <span class="rn-diff__no" aria-hidden="true"></span><span class="rn-diff__no" aria-hidden="true">11</span>
    <span class="rn-diff__sign" aria-hidden="true">+</span>
    <span class="rn-diff__text"><span class="rn-diff__kind">추가</span>이미다졸 500 <ins class="rn-diff__word">mM 으로</ins> 용출했어요.</span>
  </li>
</ol>
<%# 바뀐 줄만: data-mode="changes" 면 same 줄을 숨긴다(분절 .segment 의 ds:segment 로 바꾼다) %>

에디터 도구막대

본문 위에 서식 버튼을 묶음별로 두고, 오른쪽에 자동 저장 상태를 한 줄로 알려요. 화살표 키로 버튼 사이를 옮겨요.

18:48에 저장했어요

본문을 고치거나 버튼을 눌러 보세요. 잠깐 '저장하고 있어요' 가 보인 뒤 지금 시각으로 바뀌어요. 키트에 도구막대·서식 아이콘이 없어 rn_editor_glyph 로 그렸어요.

ERB
<div class="rn-editor__bar">
  <div class="rn-toolbar" role="toolbar" aria-label="노트 서식" aria-controls="note-body">
    <div class="rn-toolbar__group" role="group" aria-label="글자">
      <button class="btn btn--ghost btn--md btn--square rn-tool" type="button" aria-label="굵게" aria-pressed="false" data-tip="굵게"><%= rn_editor_glyph(:bold) %></button>
      <button class="btn btn--ghost btn--md btn--square rn-tool" type="button" aria-label="글머리 목록" aria-pressed="false" tabindex="-1"><%= rn_editor_glyph(:list) %></button>
    </div>
    <span class="rn-toolbar__sep" aria-hidden="true"></span>
    <div class="rn-toolbar__group" role="group" aria-label="넣기">
      <button … aria-label="표 넣기" tabindex="-1"><%= rn_editor_glyph(:table) %></button>
      <button … aria-label="이미지 넣기" tabindex="-1"><%= icon("image") %></button>
      <button … aria-label="수식 넣기" tabindex="-1"><%= rn_editor_glyph(:formula) %></button>
    </div>
  </div>
  <p class="rn-autosave" data-state="saved" role="status">   <%# saving 이면 시계 아이콘 + '저장하고 있어요' %>
    <%= icon("check", size: "sm", class: "rn-autosave__done") %><%= icon("clock", size: "sm", class: "rn-autosave__busy") %>
    <span><%= ko_time(@note.saved_at, with_date: false) %>에 저장했어요</span>
  </p>
</div>
<%# 도구막대 안은 Tab 한 번으로 들어오고 ←→·Home·End 로 옮긴다(roving tabindex) %>

첨부 파일

파일 종류 아이콘·크기·올린 사람을 한 줄에. 내려받기는 바로, 미리 보기·지우기는 '더 보기' 에 둬요.

파일을 끌어다 놓으세요

또는 눌러서 고르기 · 사진·PDF·측정 데이터

gel_0927_western.tif이미지 · 4.8MB · 김지수 ·
발현조건_측정값.xlsx측정 데이터 · 86KB · 김지수 ·
프로토콜_scFv_v2.pdfPDF 문서 · 1.2MB · 박도윤 ·
현미경_관찰_0927.mp4영상 · 23MB · 이서연 ·

파일 4개

ERB
<div class="drop" data-drop="#new-files" tabindex="0" role="button" aria-label="파일 올리기">
  <%= icon("download", size: "lg", class: "drop__icon") %>
  <p class="t-body-sm w-600 ink">파일을 끌어다 놓으세요</p>
  <input type="file" multiple hidden>
</div>
<div class="group list list--inset-divider">
  <% @note.attachments.each do |f| %>
    <%= rn_file_row(name: f.filename.to_s, size: f.byte_size, uploaded_by: f.uploader.name, uploaded_at: f.created_at,
                    download_href: rails_blob_path(f, disposition: :attachment)) %>
  <% end %>
</div>
<%# '더 보기' 메뉴에서 고르면 ds:select(value: preview · delete). 지우기는 dsConfirm(tone: 'danger') 로 한 번 묻는다 %>
<%# rn_file_icon("gel.tif") · rn_file_kind("xlsx") → "측정 데이터" · rn_file_size(5_033_164) → "4.8MB" %>

키워드 칩

노트에 붙일 키워드를 켜고 꺼요. 켜진 칩은 체크가 붙고, 새 키워드는 적어서 더해요.

노트 키워드

4개 붙었어요

20자까지 적을 수 있어요. 켜진 칩을 한 번 더 누르면 빠져요.

ERB
<div class="choices choices--wrap" data-multi role="group" aria-label="노트 키워드">
  <% @note.keywords.each do |k| %>
    <button class="choices__item" type="button" aria-pressed="true"><%= k %></button>
  <% end %>
  <% @suggested.each do |k| %>
    <button class="choices__item" type="button" aria-pressed="false"><%= k %></button>
  <% end %>
</div>
<div class="field">
  <label class="field__label" for="tag-input">키워드 더하기</label>
  <div class="rn-tags__add">
    <input class="input" id="tag-input" placeholder="예: 유도 시간" enterkeyhint="done" aria-describedby="tag-help">
    <%= ui_button "더하기", icon_name: "plus" %>
  </div>
  <p class="field__help" id="tag-help">20자까지 적을 수 있어요.</p>
</div>
<%# 누르면 ds:choice. 이미 붙은 키워드면 칸 아래 .field__error + aria-invalid %>

공유와 권한

사람마다 소유자·편집·보기 가운데 하나. 소유자를 넘길 때만 확인 창으로 한 번 묻고, 나머지는 바로 바뀌어요.

김지수 연구원나 · 과제 책임
박도윤 책임연구원검토자
이서연 선임연구원한빛바이오 연구소

초대한 사람은 '보기' 로 시작해요. 같은 연구소 사람만 찾을 수 있어요(예시).

ERB
<div class="group list list--inset-divider">
  <% @note.members.each do |m| %>
    <%= rn_member_row(id: "member-#{m.id}", name: m.name, sub: m.team, tone: "accent", role: m.role) %>
  <% end %>
</div>
<%# 권한 선택기만 따로: rn_role_picker(id:, labelled_by:, role: :edit) → 소유자·편집·보기, 고르면 ds:change %>
<%# 소유자를 넘길 때만 dsConfirm('소유자를 넘길까요?'). 소유자가 스스로 권한을 낮추면 dsAlert 로 이유를 알린다 %>

과제 선택 사이드 목록

왼쪽에서 과제를 고르면 오른쪽 목록이 그 과제 노트만 남아요. 좁은 칸에서는 과제 목록이 위로 올라가요.

ERB
<div class="rn-browse">
  <div class="rn-browse__grid">
    <nav class="rn-projects" aria-label="과제">
      <label class="searchbar"><%= icon("search", size: "sm") %><input class="searchbar__input" type="search" placeholder="과제 찾기" aria-label="과제 찾기"></label>
      <ul class="rn-projects__list" data-edge-fade>
        <% @projects.each do |p| %>
          <li><%= link_to project_notes_path(p), class: "rn-project", "aria-current": (p == @project).to_s do %>
            <%= icon("folder", size: "sm", class: "rn-project__ico") %><span class="rn-project__name"><%= p.name %></span>
            <span class="rn-project__count tnum"><%= p.notes_count %></span>
          <% end %></li>
        <% end %>
      </ul>
    </nav>
    <div class="rn-browse__main"><%# rn_note_row … 또는 ui_empty %></div>
  </div>
</div>
<%# 칸 폭 36rem 이상은 왼쪽 세로 목록, 그보다 좁으면 같은 목록이 가로로 미는 칩 줄이 된다(컨테이너 질의) %>

무엇으로 만들었나

조합마다 키트 조각과 도우미를 적었어요. 키트에 없는 조각(버전 비교·도구막대·과제 목록)은 research.css 의 rn- 로 만들었어요.

조합키트 조각도우미
상태 배지.badge + 아이콘rn_status_badge · rn_status_label · rn_status
노트 카드.card .pressablern_note_card
목록 행.group .list .row-itemrn_note_row · rn_note_meta
노트 머리.detail-head .kv--rowsrn_hash · rn_avatar
실험 기록 본문.prose .table-dense .table-scroll-
전자서명.avatars .statusrn_avatar · dsConfirm
검토 요청.sheet .picker .textareaui_sheet · dsOpen
변경 기록.timelineago
버전 비교키트에 없음 → .rn-diff-
도구막대.btn--square (도구막대는 키트에 없음 → .rn-toolbar)rn_editor_glyph
첨부 파일.drop .row-item .menu .emptyrn_file_icon · rn_file_kind · rn_file_size
키워드.choices[data-multi]-
공유와 권한.picker--sm .row-item-
과제 목록.searchbar (사이드 목록은 .rn-projects)-

18모바일

좁은 화면에서 먼저 맞춰요

360px 에서 맞으면 넓은 화면은 여유예요. 숫자로 정한 기준과, 폭이 바뀔 때 같은 컴포넌트가 어떻게 모양을 바꾸는지 모았어요.

360px 에서 지킬 숫자

화면을 만들 때마다 이 여섯 가지를 먼저 봐요. 아래 예시에서 하나씩 눌러 확인할 수 있어요.

  • 44px 적중 영역

    보이는 크기가 작아도 누르는 범위는 44px 이에요.

    ::before
  • 16px 입력 글자

    그보다 작으면 iOS 가 칸을 누를 때 화면을 확대해요.

    .input
  • 0 가로 넘침

    표·코드·띠만 자기 칸 안에서 옆으로 밀어요.

    .table-scroll
  • env() 안전 영역

    홈 막대와 노치에 버튼이 가리지 않게 비워요.

    --safe-b
  • 58·64 하단 쌓임

    탭바 위에 입력 바, 그 위에 토스트가 쌓여요.

    --tabbar-h
  • 200% 확대해도 읽혀요

    확대를 막지 않아요. 200% 로 키워도 잘리거나 겹치지 않아요.

    user-scalable

적중 영역 44px

작은 컨트롤은 크기를 키우지 않고 가상 요소로 누르는 범위만 넓혀요. 켜 두면 44px 격자 위에 그 범위가 칠해져요.

보이는 28 · 누르는 44
보이는 36 · 누르는 44
보이는 30 · 누르는 44
gel.tif 빼기 24 · 누르는 44
상자 20 · 줄 높이 44
기록 번호 아이콘 20 · 누르는 44

붙어 있는 컨트롤끼리는 넓힌 범위가 겹치지 않게 8px 넘게 띄워요. 겹치면 버튼 자체를 44px 로 만들어요.

ERB
/* 키트가 이미 넓혀 둔 것: btn--xs · btn--sm · segment__item · filechip__x · tip-trigger · 아이콘 버튼 */
/* 새로 만든 작은 컨트롤도 같은 방법으로. 보이는 크기는 그대로, 누르는 범위만 44px */
.my-chip__x{position:relative}
.my-chip__x::before{
  content:"";position:absolute;inset:50% auto auto 50%;translate:-50% -50%;
  width:max(100%,44px);height:max(100%,44px);
}
/* 이웃과 붙은 컨트롤(입력 안 아이콘 버튼 둘)은 가상 요소로 넓히지 않고 버튼 자체를 40×44 로 */

입력 글자 16px

입력칸은 작은 것(36)도 큰 것(52)도 글자가 16px 이에요. 오른쪽 숫자는 지금 화면에서 잰 값이에요.

16px
16px
16px
ERB
<%# 입력칸은 크기(sm 36 · md 44 · lg 52)와 상관없이 글자가 16px 이다. 글자만 줄이지 않는다. %>
<input class="input input--sm" id="reagent" placeholder="예: IPTG">
<input class="input" id="memo" placeholder="예: 18°C 에서 16시간 배양">
<input class="input input--lg" id="record" placeholder="예: RN-2026-0412">

<%# 화면 전용 CSS 에서 입력 글자를 바꿔야 하면 16px 아래로 내리지 않는다 %>
.my-field .input{font-size:var(--t-body-md)}   /* 16px */

가로 넘침 0

긴 글자는 줄을 바꾸고, 넓은 표는 자기 칸 안에서만 밀고, 격자 칸은 minmax(0,1fr) 로 내용이 폭을 밀지 못하게 해요.

공백 없는 긴 글자overflow-wrap:anywhere

290488b64fef7d8c1a7b828ad4d2409051e2edd1cd579803c0374d9079f31c84

넓은 표.table-scroll안에서만 옆으로 밀려요
시약농도양로트 번호유효 기한
IPTG1 M50 µLLT-2409-1182027. 1/26(화)
이미다졸500 mM20 mLIM-2407-0211/12(목)
격자 칸minmax(0,1fr)긴 파일 이름은 말줄임
항체단편_발현조건_비교_3차_원본데이터.xlsx SDS-PAGE_가용성분획_18도_0927.tif
ERB
/* 공백 없는 긴 글자(해시·주소·파일 이름): 칸 끝에서 끊는다 */
.hash{overflow-wrap:anywhere}

/* 넓은 표: 표 자체는 넓게 두고 감싼 칸 안에서만 옆으로 민다 */
<div class="table-scroll"><table class="table table-dense">…</table></div>

/* 격자 칸: 내용이 칸 폭을 밀지 못하게 minmax(0,1fr), 긴 이름은 말줄임 */
.files{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}
.files > span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}

안전 영역과 하단 쌓임

홈 막대 위에 탭바, 그 위에 입력 바, 맨 위에 토스트가 떠요. 없는 층은 변수를 0 으로 덮으면 위 층이 알아서 내려와요.

  1. 토스트 입력 바 위로 띄워서 떠요. 시트가 열려 있으면 화면 위쪽으로 가요.
  2. 입력 바 64px --dock-h 메모·대화처럼 늘 떠 있는 입력이에요. 없는 화면은 0 으로 덮어요.
  3. 탭바 58px --tabbar-h 앱 첫 화면의 아래 메뉴. 불투명 바탕이라 뒤 글자가 비치지 않아요.
  4. 홈 막대 --safe-b env(safe-area-inset-bottom) 만큼 비워 버튼이 가리지 않아요.
ERB
/* 아래에서부터: env(safe-area-inset-bottom) → 탭바 --tabbar-h(58) → 입력 바 --dock-h(64) → 토스트 */
.my-bottom-bar{padding-bottom:calc(var(--s-12) + var(--safe-b))}   /* --safe-b = env(safe-area-inset-bottom,0px) */

<%# 탭바도 입력 바도 없는 화면: 변수를 0 으로 덮으면 토스트·하단 바가 알아서 내려온다(CSS 변수 지정만 인라인 허용) %>
<div class="app" style="--tabbar-h:0px; --dock-h:0px">…</div>

폭에 따라 바뀌는 것

모바일용 마크업을 따로 만들지 않아요. 같은 컴포넌트가 폭에 따라 모양을 바꿔요. 지금 화면에 걸린 줄이 칠해져요.

지금 화면 360px· 창 크기를 바꾸면 따라 바뀌어요

폭달라지는 것
~639지금 한 열이에요. 격자와 벤토가 모두 세로로 쌓여요.
640~767지금 좌우 여백만 넓어져요. 선택기·메뉴가 드롭다운으로 떠요.
768~1023지금 시트가 가운데 창이 되고 격자가 2열이 돼요.
1024~1279지금 상단 내비가 펼쳐지고 햄버거가 사라져요. 앱 화면은 가운데 폭으로 모여요.
1280~지금 좌우 여백이 40px 이 돼요.
1440~지금 왼쪽 진행 눈금 레일이 보여요.

iOS 에서 자주 깨지는 것

아이폰 사파리에서 특히 자주 보는 증상이에요. 키트가 대부분 막아 두었고, 새로 만들 때 빠뜨리기 쉬운 것만 남았어요.

증상원인처리
입력칸을 누르면 화면이 커져요입력 글자가 16px 보다 작아요입력 글자 16px(키트 기본값)
창 뒤 화면이 같이 스크롤돼요overflow:hidden 만으로는 안 막혀요position:fixed + top:-scrollY(ds.js 가 해요)
하단 버튼이 홈 막대에 가려요안전 영역을 빼먹었어요padding-bottom: env(safe-area-inset-bottom)
화면 높이가 주소창에 가려요vh 는 주소창이 없을 때 높이로 재요앱 껍데기는 dvh, 스크롤 장면은 --fx-vh
가로 스크롤이 생겨요고정 폭 요소가 있어요minmax(0,1fr) · max-width:100%

한국어 줄바꿈

낱글자에서 끊기면 '요청하/기' 처럼 읽혀요. 어절 단위(keep-all)로 끊고, 마지막 줄에 한 어절만 남지 않게(pretty) 해요. 폭을 바꿔 보세요.

쓰지 않는 모양word-break:normal

보완 요청을 받으면 고친 뒤 검토를 다시 요청해 주세요.

쓰는 모양keep-all · pretty

보완 요청을 받으면 고친 뒤 검토를 다시 요청해 주세요.

168px

제목·문단·목록·버튼·배지에는 base.css 가 이미 keep-all 을 걸어 두었어요. span·div 로 긴 글을 새로 만들 때만 두 줄을 직접 더해요.

ERB
/* base.css 가 제목·문단·목록·버튼·배지에 이미 건다. span·div 로 긴 글을 새로 만들 때만 더한다 */
.my-bubble{
  word-break:keep-all;          /* 어절 단위로 끊는다('요청하/기' 막기) */
  overflow-wrap:anywhere;       /* 공백 없는 긴 글자는 마지막에 끊는다 */
  text-wrap-style:pretty;       /* 마지막 줄에 한 어절만 남지 않게(크롬 130+·사파리 26+) */
}
h2,h3{text-wrap:balance}        /* 제목은 줄 길이를 고르게 */

가로 스크롤 띠

좁은 화면에서 카드를 옆으로 늘어놓을 때는 다음 카드가 살짝 보이게 둬요. 더 있다는 걸 알리는 가장 싼 방법이에요.

ERB
<%# 좁은 화면: 카드 폭 min(78vw,340px) + 다음 카드가 살짝 보임. 넓은 화면: 340px 칸이 여러 장 %>
<div class="rail" data-snapdots aria-label="최근 노트">
  <% @recent_notes.each do |note| %>
    <%= rn_note_card(title: note.title, project: note.project.name, author: note.author.name,
                     status: note.status, updated_at: note.updated_at, version: note.version,
                     files: note.attachments.size, href: note_path(note)) %>
  <% end %>
</div>
<%# data-snapdots: 넘칠 때만 진행 점을 붙이고, 점을 누르면 그 카드로 넘어간다(fx.js) %>

연출은 끄지 않고 바꿔요

모바일에서도 스크롤 연출을 살려요. 좁은 화면에 맞는 모양으로 바꾸고, 모션 축소 설정이면 움직임 없이 끝 모습으로 둬요.

연출넓은 화면좁은 화면모션 축소
.scene왼쪽 설명이 지나가면 오른쪽에 붙은 화면이 바뀌어요폰이 위에 붙고 설명 카드가 그 위로 지나가요움직임 없이 화면만 바뀌어요
.stackcards카드가 겹쳐 쌓이며 뒤 카드가 작아져요같아요. 화면보다 긴 카드만 붙지 않아요쌓지 않고 나열해요
.hscroll세로 스크롤이 가로 띠를 밀어요손가락으로 넘기는 스냅 띠와 진행 점스냅 띠
data-scrub진행도에 맞춰 떠오르고 펼쳐져요같아요끝 모습
data-parallax가운데서 멀수록 밀려요같아요없어요
data-count들어올 때 한 번 세요같아요마지막 숫자
연출 섹션에서 직접 움직여 보기

새 화면 검수 목록

화면을 끝내기 전에 위에서부터 한 번 내려가요. 고른 것은 이 브라우저에만 남고 다른 사람에게는 보이지 않아요.

22개 가운데 0개 확인했어요

레이아웃0/4

조작과 접근성0/5

테마와 움직임0/3

상태0/4

문구0/4

성능0/2

검토 의견이 있나요?주소를 붙여 보내 주세요