21st.dev 사례 연구

Gather · A plan in three steps

섹션 · MIT

섹션 더 보기
CASE STUDY
Gather · A plan in three steps 정적 미리보기

갤러리 안에서는 화면을 벗어나거나 움직임을 멈추면 예제도 닫힙니다. 다시 재생할 때는 처음부터 시작해요.

큰 화면으로 보기 새 탭실행 HTML 저장

저장한 HTML에서는 갤러리의 재생 설정이 적용되지 않아요.

SOURCE FILES

독립 예제 코드 읽기

7개 파일

사례의 구도나 문서의 원리를 바탕으로 새로 만든 예제입니다. 아래 코드를 복사해 직접 응용할 수 있습니다. 실행 HTML에는 미리보기를 위한 실행 환경이 들어 있습니다.

index.html독립 구현
파일 저장

<!doctype html>
<!-- Independent observation study. Copyright (c) 2026 StyleGallery contributors. MIT. -->
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src 'none'; connect-src 'none'; media-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'"><title>Gather · A plan in three steps</title><style>/* Original learning-example styling. Copyright (c) 2026 StyleGallery contributors. MIT. */
* { box-sizing: border-box; }
body { margin: 0; }
button, input, select { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: default; }
button, input, select { touch-action: manipulation; }
button:focus-visible, input:focus-visible, select:focus-visible, [tabindex="-1"]:focus-visible { outline: 3px solid #307865; outline-offset: 3px; }
[hidden] { display: none !important; }
h1, h2, p { margin: 0; }
svg { display: block; }
.sr-only { block-size: 1px; clip-path: inset(50%); inline-size: 1px; overflow: hidden; position: absolute; white-space: nowrap; }
.no-script { font: 12px/1.5 system-ui, sans-serif; padding: 12px; }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; scroll-behavior: auto !important; transition: none !important; } }

body { background: #f0ece4; color: #3e3a31; font: 12px/1.45 Arial, Helvetica, sans-serif; padding: 18px; }
.visit_layout { background: #fffcf5; border: 1px solid #e0d9cc; border-radius: 14px; display: grid; grid-template-columns: 168px minmax(0, 1fr); margin-inline: auto; max-inline-size: 840px; min-block-size: 384px; overflow: hidden; }
.visit_art { background: #e9c798; color: #794e32; display: flex; flex-direction: column; padding: 21px 22px; }
.visit_wordmark { font-family: Georgia, 'Times New Roman', serif; font-size: 27px; letter-spacing: -1.7px; line-height: 1; }
.visit_wordmark span { display: inline-block; font: 8px/1 Arial, sans-serif; margin-inline-start: 3px; vertical-align: top; }
.visit_art_center { align-items: center; display: flex; flex: 1; flex-direction: column; justify-content: center; margin-block: 8px; }
.visit_art_center svg { inline-size: 132px; max-inline-size: 100%; }
.visit_art_center p { font-family: Georgia, 'Times New Roman', serif; font-size: 23px; letter-spacing: -.6px; line-height: 1.05; text-align: center; }
.visit_art_footer { font-size: 8px; letter-spacing: 2px; line-height: 1.9; }
.visit_art_footer span { color: #936e4d; font-size: 8px; letter-spacing: .1px; }
.visit_form_area { min-inline-size: 0; padding: 22px 22px 18px; }
.visit_eyebrow { color: #9b8f79; font-size: 7px; letter-spacing: 1.65px; }
.visit_header h1 { font-family: Georgia, 'Times New Roman', serif; font-size: 29px; font-weight: 400; letter-spacing: -.8px; line-height: 1.2; margin-block-start: 5px; }
.visit_progress_row { align-items: center; display: flex; gap: 12px; margin-block: 16px; }
.visit_progress_row progress { accent-color: #a2623d; appearance: none; background: #eee7db; block-size: 4px; border: 0; border-radius: 10px; flex: 1; inline-size: 100%; overflow: hidden; }
.visit_progress_row progress::-webkit-progress-bar { background: #eee7db; }
.visit_progress_row progress::-webkit-progress-value { background: #a2623d; }
.visit_progress_row progress::-moz-progress-bar { background: #a2623d; }
.visit_progress_row p { color: #99866e; flex-shrink: 0; font: 9px/1 monospace; }
.visit_step_heading { font-size: 14px; font-weight: 500; margin-block-end: 14px; }
.visit_panel { border: 0; margin: 0; min-block-size: 150px; min-inline-size: 0; padding: 0; }
.visit_field_grid { display: grid; gap: 12px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.visit_field { display: grid; gap: 5px; min-inline-size: 0; }
.visit_field label { color: #766b58; font-size: 9px; }
.visit_field input, .visit_field select { background: #fffef9; block-size: 33px; border: 1px solid #ded6c8; border-radius: 5px; color: #655d4d; font-size: 10px; inline-size: 100%; min-inline-size: 0; padding: 5px 7px; }
.visit_field input::placeholder { color: #b0a18a; }
.visit_field [aria-invalid="true"] { border-color: #ac492e; }
.field_error { color: #a1412c; font-size: 9px; }
.visit_tip { align-items: baseline; color: #9c8a6e; display: flex; flex-wrap: wrap; font-size: 9px; gap: 4px; margin-block-start: 14px; }
.visit_tip span { color: #b78251; margin-inline-end: 3px; }
.visit_tip button { background: transparent; border: 0; color: #8d6842; font-size: 9px; padding: 2px 0; text-decoration: underline; text-underline-offset: 3px; }
.visit_actions { align-items: center; border-block-start: 1px solid #ece4d7; display: flex; gap: 8px; justify-content: space-between; margin-block-start: 13px; padding-block-start: 14px; }
.visit_local_note { color: #9a8c74; font-size: 8px; max-inline-size: 112px; }
.visit_action_buttons { align-items: center; display: flex; flex-shrink: 0; gap: 6px; }
.visit_back { background: transparent; border: 1px solid #e3dac9; border-radius: 5px; color: #7c6e58; font-size: 10px; min-block-size: 32px; padding: 6px 9px; }
.visit_next { background: #96613d; border: 1px solid #96613d; border-radius: 5px; color: #fffbf1; font-size: 10px; min-block-size: 32px; padding: 6px 11px; }
.visit_next:hover { background: #784a2b; }
.visit_next:disabled { background: #b5a087; border-color: #b5a087; }
.visit_next span { margin-inline-start: 7px; }
.visit_back:hover { background: #f4eddf; }
.workshop_choice { align-items: center; background: #faf5e9; border: 1px solid #e5dccb; border-radius: 6px; display: flex; gap: 9px; margin-block-end: 8px; padding: 9px; }
.workshop_choice:has(input:checked) { background: #f3e5ce; border-color: #cda576; }
.workshop_choice input { accent-color: #95613c; margin: 0; }
.workshop_choice strong { display: block; font-size: 10px; font-weight: 500; }
.workshop_choice small { color: #9c896b; display: block; font-size: 8px; margin-block-start: 2px; }
.workshop_number { color: #ba9b74; font-family: Georgia, serif; font-size: 18px; margin-inline-start: auto; }
.visit_summary { margin: 0; }
.visit_summary div { border-block-end: 1px solid #eee6d7; display: grid; gap: 14px; grid-template-columns: 60px minmax(0, 1fr); padding-block: 11px; }
.visit_summary dt { color: #a18c6c; font-size: 10px; }
.visit_summary dd { font-size: 10px; margin: 0; overflow-wrap: anywhere; }
.visit_review_note { color: #9c896c; font-size: 9px; margin-block-start: 12px; }
@media (min-width: 900px) { .visit_layout { grid-template-columns: 240px minmax(0, 1fr); min-block-size: 465px; } .visit_form_area { padding: 32px; } .visit_panel { min-block-size: 215px; } .visit_header h1 { font-size: 37px; } .visit_art_center svg { inline-size: 160px; } .visit_art_center p { font-size: 32px; } .visit_field input, .visit_field select { block-size: 40px; font-size: 12px; } }
@media (max-width: 520px) { body { padding: 8px; } .visit_layout { grid-template-columns: minmax(0, 1fr); } .visit_art { align-items: center; flex-direction: row; gap: 10px; justify-content: space-between; padding: 14px 18px; } .visit_art_center { display: none; } .visit_art_footer { text-align: end; } .visit_form_area { padding: 20px 18px; } .visit_panel { min-block-size: 0; } .visit_field_grid { grid-template-columns: minmax(0, 1fr); } .visit_field input, .visit_field select { block-size: 38px; font-size: 12px; } .visit_field label { font-size: 10px; } .visit_actions { align-items: stretch; flex-direction: column; margin-block-start: 18px; } .visit_action_buttons { justify-content: flex-end; } .visit_local_note { max-inline-size: none; } .visit_next, .visit_back { min-block-size: 36px; } }
</style></head><body><main class="visit_layout">
  <aside class="visit_art" aria-label="Gather studio"><p class="visit_wordmark">gather<span aria-hidden="true">®</span></p><div class="visit_art_center"><svg viewBox="0 0 160 175" width="160" height="175" aria-hidden="true"><path d="M70 72c-4-22 8-48 27-57M79 74c19-29 42-34 49-34" fill="none" stroke="#53563a" stroke-width="2"/><path d="M90 30c-17-11-22-1-14 10 6 8 13 8 13 8M98 23c8-22 22-18 19-4-2 10-11 14-19 14M106 50c9-20 25-14 16-3-7 8-15 8-15 8" fill="#727448"/><ellipse cx="78" cy="152" rx="52" ry="8" fill="#b9664140"/><path d="M53 70h50l-5 22c21 20 22 49 8 58-12 8-43 8-56 0-15-10-11-39 8-58Z" fill="#b86543"/><path d="M62 94c-12 17-15 33-10 43M68 98c-11 20-10 36-5 46M79 98c-6 21-5 36 0 48M89 98c1 15 6 29 3 46M97 98c10 18 12 31 7 40" fill="none" stroke="#d9986c" stroke-width="2"/><ellipse cx="78" cy="70" rx="25" ry="5" fill="#85482f"/><ellipse cx="78" cy="69" rx="18" ry="2.3" fill="#633d29"/></svg><p>Good things<br>take shape.</p></div><p class="visit_art_footer">OPEN STUDIO<br><span>A little time to make.</span></p></aside>
  <section class="visit_form_area" aria-labelledby="visit-title"><header class="visit_header"><p class="visit_eyebrow">YOUR NEXT CREATIVE MORNING</p><h1 id="visit-title">Make some room.</h1></header>
  <div class="visit_progress_row"><progress id="visit-progress" max="3" value="2" aria-label="Current step"></progress><p data-step-count>02 / 03</p></div>
  <form data-visit-form novalidate><h2 class="visit_step_heading" tabindex="-1" data-step-heading>Plan your visit</h2>
    <fieldset class="visit_panel visit_workshops" data-step-panel="1" hidden disabled><legend class="sr-only">Choose a workshop</legend><label class="workshop_choice"><input type="radio" name="workshop" value="print" checked><span><strong>Print workshop</strong><small>Make a little edition of your own.</small></span><span class="workshop_number" aria-hidden="true">01</span></label><label class="workshop_choice"><input type="radio" name="workshop" value="clay"><span><strong>Clay study</strong><small>Start with a handful of possibilities.</small></span><span class="workshop_number" aria-hidden="true">02</span></label><label class="workshop_choice"><input type="radio" name="workshop" value="open"><span><strong>Open studio</strong><small>Bring an idea. Find your own pace.</small></span><span class="workshop_number" aria-hidden="true">03</span></label><p class="field_error" data-error="workshop" id="workshop-error" hidden></p></fieldset>
    <fieldset class="visit_panel" data-step-panel="2"><legend class="sr-only">Visit details</legend><div class="visit_field_grid"><div class="visit_field"><label for="visit-time">Session time</label><select id="visit-time" name="time" required aria-describedby="time-error"><option value="">Choose a time</option value="morning">Morning · 10:00</option><option value="afternoon">Afternoon · 14:00</option><option value="evening">Evening · 18:00</option></select><p class="field_error" data-error="time" id="time-error" hidden></p></div><div class="visit_field"><label for="visit-date">Visit date</label><input id="visit-date" name="date" type="date" required aria-describedby="date-error"><p class="field_error" data-error="date" id="date-error" hidden></p></div><div class="visit_field"><label for="visit-name">Name for your plan</label><input id="visit-name" name="name" type="text" autocomplete="off" placeholder="Your name" required maxlength="80" aria-describedby="name-error"><p class="field_error" data-error="name" id="name-error" hidden></p></div><div class="visit_field"><label for="visit-guests">People joining</label><select id="visit-guests" name="guests" aria-describedby="guests-error"><option value="1">Just me</option><option value="2">Two people</option><option value="3">Three people</option><option value="4">Four people</option></select><p class="field_error" data-error="guests" id="guests-error" hidden></p></div></div><p class="visit_tip"><span aria-hidden="true">✦</span> Just exploring? <button type="button" data-sample>Try a sample plan</button></p></fieldset>
    <fieldset class="visit_panel" data-step-panel="3" hidden disabled><legend class="sr-only">Review your plan</legend><dl class="visit_summary"><div><dt>Workshop</dt><dd data-summary="workshop">Print workshop</dd></div><div><dt>When</dt><dd data-summary="when">Choose your visit details</dd></div><div><dt>Who</dt><dd data-summary="who">Your name and guests</dd></div></dl><p class="visit_review_note" data-review-note>Take a moment to check the details. You can still go back.</p></fieldset>
    <div class="visit_actions"><p class="visit_local_note" data-form-status role="status">Demo only. Nothing is booked.</p><div class="visit_action_buttons"><button class="visit_back" type="button" data-back>Back</button><button class="visit_next" type="submit" data-next disabled>Review plan <span aria-hidden="true">↗</span></button></div></div>
  </form><noscript><p class="no-script">This is the visit-details view. Step navigation requires JavaScript.</p></noscript></section>
</main><script>/* Original learning example. Copyright (c) 2026 StyleGallery contributors. MIT. */
(() => {
'use strict';
const id = "21st-576ca9a8dddb";
function reportPreviewError(error) { parent.postMessage({type:'sg-preview-error',id,message:String(error?.message || error || 'Initialization failed')},'*'); }
window.addEventListener('error', event => reportPreviewError(event.error || event.message));
window.addEventListener('unhandledrejection', event => reportPreviewError(event.reason));
try {
function createVisitState() {
  return { step: 2, complete: false, errors: {}, draft: { workshop: 'print', time: '', date: '', name: '', guests: '1' } };
}
function visitIssues(draft, step) {
  const errors = {};
  if (step === 1 || step === 3) {
    if (!['print', 'clay', 'open'].includes(draft.workshop)) errors.workshop = 'Choose a workshop.';
  }
  if (step === 2 || step === 3) {
    if (!['morning', 'afternoon', 'evening'].includes(draft.time)) errors.time = 'Choose a session time.';
    const date = /^\d{4}-\d{2}-\d{2}$/.test(draft.date) ? new Date(draft.date + 'T12:00:00Z') : null;
    if (!date || !Number.isFinite(date.valueOf()) || date.toISOString().slice(0, 10) !== draft.date) errors.date = 'Choose a valid visit date.';
    if (!draft.name.trim()) errors.name = 'Add a name for your plan.';
    if (!['1', '2', '3', '4'].includes(draft.guests)) errors.guests = 'Choose one to four people.';
  }
  return errors;
}
function updateVisit(state, action) {
  if (action.type === 'edit' && Object.hasOwn(state.draft, action.field)) {
    const errors = { ...state.errors };
    delete errors[action.field];
    return { ...state, complete: false, draft: { ...state.draft, [action.field]: action.value }, errors };
  }
  if (action.type === 'back') return { ...state, step: Math.max(1, state.step - 1), complete: false, errors: {} };
  if (action.type === 'edit-plan') return { ...state, step: 2, complete: false, errors: {} };
  if (action.type === 'sample') return { ...state, draft: { ...state.draft, time: 'afternoon', date: '2026-10-14', name: 'Avery', guests: '2' }, errors: {} };
  if (action.type === 'next') {
    const errors = visitIssues(state.draft, state.step);
    if (Object.keys(errors).length) return { ...state, errors };
    return state.step === 3 ? { ...state, complete: true, errors: {} } : { ...state, step: state.step + 1, errors: {} };
  }
  return state;
}

let state = createVisitState();
const form = document.querySelector('[data-visit-form]');
const heading = document.querySelector('[data-step-heading]');
const panels = [...document.querySelectorAll('[data-step-panel]')];
const next = document.querySelector('[data-next]');
const back = document.querySelector('[data-back]');
const workshops = { print: 'Print workshop', clay: 'Clay study', open: 'Open studio' };
const times = { morning: 'Morning · 10:00', afternoon: 'Afternoon · 14:00', evening: 'Evening · 18:00' };
function renderVisitErrors() {
  form.querySelectorAll('[data-error]').forEach(node => { const message = state.errors[node.dataset.error] || ''; node.textContent = message; node.hidden = !message; });
  form.querySelectorAll('[name]').forEach(control => { const invalid = Boolean(state.errors[control.name]); if (invalid) control.setAttribute('aria-invalid', 'true'); else control.removeAttribute('aria-invalid'); });
}
function renderVisit(focusHeading = false) {
  panels.forEach(panel => { const active = Number(panel.dataset.stepPanel) === state.step; panel.hidden = !active; panel.disabled = !active; });
  heading.textContent = state.complete ? 'Your plan is ready.' : ['Choose your workshop', 'Plan your visit', 'A moment to review'][state.step - 1];
  document.querySelector('#visit-progress').value = state.step;
  document.querySelector('[data-step-count]').textContent = String(state.step).padStart(2, '0') + ' / 03';
  document.querySelector('[data-summary="workshop"]').textContent = workshops[state.draft.workshop];
  document.querySelector('[data-summary="when"]').textContent = state.draft.date && times[state.draft.time] ? state.draft.date + ' · ' + times[state.draft.time] : 'Choose your visit details';
  document.querySelector('[data-summary="who"]').textContent = state.draft.name ? state.draft.name + ' · ' + state.draft.guests + (state.draft.guests === '1' ? ' person' : ' people') : 'Your name and guests';
  document.querySelector('[data-review-note]').textContent = state.complete ? 'Keep this idea close. This demo stores nothing after you leave.' : 'Take a moment to check the details. You can still go back.';
  back.hidden = state.step === 1 || state.complete;
  next.disabled = false;
  next.textContent = state.complete ? 'Edit plan ↗' : ['Plan your visit ↗', 'Review plan ↗', 'Save this plan ↗'][state.step - 1];
  document.querySelector('[data-form-status]').textContent = state.complete ? 'Plan prepared locally. Nothing is booked.' : 'Demo only. Nothing is booked.';
  renderVisitErrors();
  if (focusHeading) heading.focus();
}
function readVisitField(event) {
  const control = event.target;
  if (!control.name || !Object.hasOwn(state.draft, control.name) || control.type === 'radio' && !control.checked) return;
  state = updateVisit(state, { type: 'edit', field: control.name, value: control.value });
  renderVisitErrors();
}
form.addEventListener('input', readVisitField);
form.addEventListener('change', readVisitField);
back.addEventListener('click', () => { state = updateVisit(state, { type: 'back' }); renderVisit(true); });
document.querySelector('[data-sample]').addEventListener('click', () => {
  state = updateVisit(state, { type: 'sample' });
  for (const name of ['time', 'date', 'name', 'guests']) form.elements.namedItem(name).value = state.draft[name];
  renderVisitErrors();
  document.querySelector('[data-form-status]').textContent = 'Sample values added. You can change every detail.';
});
form.addEventListener('submit', event => {
  event.preventDefault();
  state = updateVisit(state, { type: state.complete ? 'edit-plan' : 'next' });
  renderVisit(Object.keys(state.errors).length === 0);
  const firstInvalid = Object.keys(state.errors)[0];
  if (firstInvalid) {
    if (state.step === 3) { state = { ...state, step: firstInvalid === 'workshop' ? 1 : 2 }; renderVisit(); }
    const target = form.querySelector('[name="' + firstInvalid + '"]');
    target?.focus();
    document.querySelector('[data-form-status]').textContent = 'Check the highlighted details before continuing.';
  }
});
renderVisit();

parent.postMessage({type:'sg-preview-ready',id},'*');
} catch (error) { reportPreviewError(error); }
})();
</script></body></html>
styles.css독립 구현
파일 저장

/* Original learning-example styling. Copyright (c) 2026 StyleGallery contributors. MIT. */
* { box-sizing: border-box; }
body { margin: 0; }
button, input, select { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: default; }
button, input, select { touch-action: manipulation; }
button:focus-visible, input:focus-visible, select:focus-visible, [tabindex="-1"]:focus-visible { outline: 3px solid #307865; outline-offset: 3px; }
[hidden] { display: none !important; }
h1, h2, p { margin: 0; }
svg { display: block; }
.sr-only { block-size: 1px; clip-path: inset(50%); inline-size: 1px; overflow: hidden; position: absolute; white-space: nowrap; }
.no-script { font: 12px/1.5 system-ui, sans-serif; padding: 12px; }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; scroll-behavior: auto !important; transition: none !important; } }

body { background: #f0ece4; color: #3e3a31; font: 12px/1.45 Arial, Helvetica, sans-serif; padding: 18px; }
.visit_layout { background: #fffcf5; border: 1px solid #e0d9cc; border-radius: 14px; display: grid; grid-template-columns: 168px minmax(0, 1fr); margin-inline: auto; max-inline-size: 840px; min-block-size: 384px; overflow: hidden; }
.visit_art { background: #e9c798; color: #794e32; display: flex; flex-direction: column; padding: 21px 22px; }
.visit_wordmark { font-family: Georgia, 'Times New Roman', serif; font-size: 27px; letter-spacing: -1.7px; line-height: 1; }
.visit_wordmark span { display: inline-block; font: 8px/1 Arial, sans-serif; margin-inline-start: 3px; vertical-align: top; }
.visit_art_center { align-items: center; display: flex; flex: 1; flex-direction: column; justify-content: center; margin-block: 8px; }
.visit_art_center svg { inline-size: 132px; max-inline-size: 100%; }
.visit_art_center p { font-family: Georgia, 'Times New Roman', serif; font-size: 23px; letter-spacing: -.6px; line-height: 1.05; text-align: center; }
.visit_art_footer { font-size: 8px; letter-spacing: 2px; line-height: 1.9; }
.visit_art_footer span { color: #936e4d; font-size: 8px; letter-spacing: .1px; }
.visit_form_area { min-inline-size: 0; padding: 22px 22px 18px; }
.visit_eyebrow { color: #9b8f79; font-size: 7px; letter-spacing: 1.65px; }
.visit_header h1 { font-family: Georgia, 'Times New Roman', serif; font-size: 29px; font-weight: 400; letter-spacing: -.8px; line-height: 1.2; margin-block-start: 5px; }
.visit_progress_row { align-items: center; display: flex; gap: 12px; margin-block: 16px; }
.visit_progress_row progress { accent-color: #a2623d; appearance: none; background: #eee7db; block-size: 4px; border: 0; border-radius: 10px; flex: 1; inline-size: 100%; overflow: hidden; }
.visit_progress_row progress::-webkit-progress-bar { background: #eee7db; }
.visit_progress_row progress::-webkit-progress-value { background: #a2623d; }
.visit_progress_row progress::-moz-progress-bar { background: #a2623d; }
.visit_progress_row p { color: #99866e; flex-shrink: 0; font: 9px/1 monospace; }
.visit_step_heading { font-size: 14px; font-weight: 500; margin-block-end: 14px; }
.visit_panel { border: 0; margin: 0; min-block-size: 150px; min-inline-size: 0; padding: 0; }
.visit_field_grid { display: grid; gap: 12px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.visit_field { display: grid; gap: 5px; min-inline-size: 0; }
.visit_field label { color: #766b58; font-size: 9px; }
.visit_field input, .visit_field select { background: #fffef9; block-size: 33px; border: 1px solid #ded6c8; border-radius: 5px; color: #655d4d; font-size: 10px; inline-size: 100%; min-inline-size: 0; padding: 5px 7px; }
.visit_field input::placeholder { color: #b0a18a; }
.visit_field [aria-invalid="true"] { border-color: #ac492e; }
.field_error { color: #a1412c; font-size: 9px; }
.visit_tip { align-items: baseline; color: #9c8a6e; display: flex; flex-wrap: wrap; font-size: 9px; gap: 4px; margin-block-start: 14px; }
.visit_tip span { color: #b78251; margin-inline-end: 3px; }
.visit_tip button { background: transparent; border: 0; color: #8d6842; font-size: 9px; padding: 2px 0; text-decoration: underline; text-underline-offset: 3px; }
.visit_actions { align-items: center; border-block-start: 1px solid #ece4d7; display: flex; gap: 8px; justify-content: space-between; margin-block-start: 13px; padding-block-start: 14px; }
.visit_local_note { color: #9a8c74; font-size: 8px; max-inline-size: 112px; }
.visit_action_buttons { align-items: center; display: flex; flex-shrink: 0; gap: 6px; }
.visit_back { background: transparent; border: 1px solid #e3dac9; border-radius: 5px; color: #7c6e58; font-size: 10px; min-block-size: 32px; padding: 6px 9px; }
.visit_next { background: #96613d; border: 1px solid #96613d; border-radius: 5px; color: #fffbf1; font-size: 10px; min-block-size: 32px; padding: 6px 11px; }
.visit_next:hover { background: #784a2b; }
.visit_next:disabled { background: #b5a087; border-color: #b5a087; }
.visit_next span { margin-inline-start: 7px; }
.visit_back:hover { background: #f4eddf; }
.workshop_choice { align-items: center; background: #faf5e9; border: 1px solid #e5dccb; border-radius: 6px; display: flex; gap: 9px; margin-block-end: 8px; padding: 9px; }
.workshop_choice:has(input:checked) { background: #f3e5ce; border-color: #cda576; }
.workshop_choice input { accent-color: #95613c; margin: 0; }
.workshop_choice strong { display: block; font-size: 10px; font-weight: 500; }
.workshop_choice small { color: #9c896b; display: block; font-size: 8px; margin-block-start: 2px; }
.workshop_number { color: #ba9b74; font-family: Georgia, serif; font-size: 18px; margin-inline-start: auto; }
.visit_summary { margin: 0; }
.visit_summary div { border-block-end: 1px solid #eee6d7; display: grid; gap: 14px; grid-template-columns: 60px minmax(0, 1fr); padding-block: 11px; }
.visit_summary dt { color: #a18c6c; font-size: 10px; }
.visit_summary dd { font-size: 10px; margin: 0; overflow-wrap: anywhere; }
.visit_review_note { color: #9c896c; font-size: 9px; margin-block-start: 12px; }
@media (min-width: 900px) { .visit_layout { grid-template-columns: 240px minmax(0, 1fr); min-block-size: 465px; } .visit_form_area { padding: 32px; } .visit_panel { min-block-size: 215px; } .visit_header h1 { font-size: 37px; } .visit_art_center svg { inline-size: 160px; } .visit_art_center p { font-size: 32px; } .visit_field input, .visit_field select { block-size: 40px; font-size: 12px; } }
@media (max-width: 520px) { body { padding: 8px; } .visit_layout { grid-template-columns: minmax(0, 1fr); } .visit_art { align-items: center; flex-direction: row; gap: 10px; justify-content: space-between; padding: 14px 18px; } .visit_art_center { display: none; } .visit_art_footer { text-align: end; } .visit_form_area { padding: 20px 18px; } .visit_panel { min-block-size: 0; } .visit_field_grid { grid-template-columns: minmax(0, 1fr); } .visit_field input, .visit_field select { block-size: 38px; font-size: 12px; } .visit_field label { font-size: 10px; } .visit_actions { align-items: stretch; flex-direction: column; margin-block-start: 18px; } .visit_action_buttons { justify-content: flex-end; } .visit_local_note { max-inline-size: none; } .visit_next, .visit_back { min-block-size: 36px; } }
함께 쓰는 파일 5개 보기
script.js독립 구현
파일 저장

/* Original learning example. Copyright (c) 2026 StyleGallery contributors. MIT. */
(() => {
'use strict';
const id = "21st-576ca9a8dddb";
function reportPreviewError(error) { parent.postMessage({type:'sg-preview-error',id,message:String(error?.message || error || 'Initialization failed')},'*'); }
window.addEventListener('error', event => reportPreviewError(event.error || event.message));
window.addEventListener('unhandledrejection', event => reportPreviewError(event.reason));
try {
function createVisitState() {
  return { step: 2, complete: false, errors: {}, draft: { workshop: 'print', time: '', date: '', name: '', guests: '1' } };
}
function visitIssues(draft, step) {
  const errors = {};
  if (step === 1 || step === 3) {
    if (!['print', 'clay', 'open'].includes(draft.workshop)) errors.workshop = 'Choose a workshop.';
  }
  if (step === 2 || step === 3) {
    if (!['morning', 'afternoon', 'evening'].includes(draft.time)) errors.time = 'Choose a session time.';
    const date = /^\d{4}-\d{2}-\d{2}$/.test(draft.date) ? new Date(draft.date + 'T12:00:00Z') : null;
    if (!date || !Number.isFinite(date.valueOf()) || date.toISOString().slice(0, 10) !== draft.date) errors.date = 'Choose a valid visit date.';
    if (!draft.name.trim()) errors.name = 'Add a name for your plan.';
    if (!['1', '2', '3', '4'].includes(draft.guests)) errors.guests = 'Choose one to four people.';
  }
  return errors;
}
function updateVisit(state, action) {
  if (action.type === 'edit' && Object.hasOwn(state.draft, action.field)) {
    const errors = { ...state.errors };
    delete errors[action.field];
    return { ...state, complete: false, draft: { ...state.draft, [action.field]: action.value }, errors };
  }
  if (action.type === 'back') return { ...state, step: Math.max(1, state.step - 1), complete: false, errors: {} };
  if (action.type === 'edit-plan') return { ...state, step: 2, complete: false, errors: {} };
  if (action.type === 'sample') return { ...state, draft: { ...state.draft, time: 'afternoon', date: '2026-10-14', name: 'Avery', guests: '2' }, errors: {} };
  if (action.type === 'next') {
    const errors = visitIssues(state.draft, state.step);
    if (Object.keys(errors).length) return { ...state, errors };
    return state.step === 3 ? { ...state, complete: true, errors: {} } : { ...state, step: state.step + 1, errors: {} };
  }
  return state;
}

let state = createVisitState();
const form = document.querySelector('[data-visit-form]');
const heading = document.querySelector('[data-step-heading]');
const panels = [...document.querySelectorAll('[data-step-panel]')];
const next = document.querySelector('[data-next]');
const back = document.querySelector('[data-back]');
const workshops = { print: 'Print workshop', clay: 'Clay study', open: 'Open studio' };
const times = { morning: 'Morning · 10:00', afternoon: 'Afternoon · 14:00', evening: 'Evening · 18:00' };
function renderVisitErrors() {
  form.querySelectorAll('[data-error]').forEach(node => { const message = state.errors[node.dataset.error] || ''; node.textContent = message; node.hidden = !message; });
  form.querySelectorAll('[name]').forEach(control => { const invalid = Boolean(state.errors[control.name]); if (invalid) control.setAttribute('aria-invalid', 'true'); else control.removeAttribute('aria-invalid'); });
}
function renderVisit(focusHeading = false) {
  panels.forEach(panel => { const active = Number(panel.dataset.stepPanel) === state.step; panel.hidden = !active; panel.disabled = !active; });
  heading.textContent = state.complete ? 'Your plan is ready.' : ['Choose your workshop', 'Plan your visit', 'A moment to review'][state.step - 1];
  document.querySelector('#visit-progress').value = state.step;
  document.querySelector('[data-step-count]').textContent = String(state.step).padStart(2, '0') + ' / 03';
  document.querySelector('[data-summary="workshop"]').textContent = workshops[state.draft.workshop];
  document.querySelector('[data-summary="when"]').textContent = state.draft.date && times[state.draft.time] ? state.draft.date + ' · ' + times[state.draft.time] : 'Choose your visit details';
  document.querySelector('[data-summary="who"]').textContent = state.draft.name ? state.draft.name + ' · ' + state.draft.guests + (state.draft.guests === '1' ? ' person' : ' people') : 'Your name and guests';
  document.querySelector('[data-review-note]').textContent = state.complete ? 'Keep this idea close. This demo stores nothing after you leave.' : 'Take a moment to check the details. You can still go back.';
  back.hidden = state.step === 1 || state.complete;
  next.disabled = false;
  next.textContent = state.complete ? 'Edit plan ↗' : ['Plan your visit ↗', 'Review plan ↗', 'Save this plan ↗'][state.step - 1];
  document.querySelector('[data-form-status]').textContent = state.complete ? 'Plan prepared locally. Nothing is booked.' : 'Demo only. Nothing is booked.';
  renderVisitErrors();
  if (focusHeading) heading.focus();
}
function readVisitField(event) {
  const control = event.target;
  if (!control.name || !Object.hasOwn(state.draft, control.name) || control.type === 'radio' && !control.checked) return;
  state = updateVisit(state, { type: 'edit', field: control.name, value: control.value });
  renderVisitErrors();
}
form.addEventListener('input', readVisitField);
form.addEventListener('change', readVisitField);
back.addEventListener('click', () => { state = updateVisit(state, { type: 'back' }); renderVisit(true); });
document.querySelector('[data-sample]').addEventListener('click', () => {
  state = updateVisit(state, { type: 'sample' });
  for (const name of ['time', 'date', 'name', 'guests']) form.elements.namedItem(name).value = state.draft[name];
  renderVisitErrors();
  document.querySelector('[data-form-status]').textContent = 'Sample values added. You can change every detail.';
});
form.addEventListener('submit', event => {
  event.preventDefault();
  state = updateVisit(state, { type: state.complete ? 'edit-plan' : 'next' });
  renderVisit(Object.keys(state.errors).length === 0);
  const firstInvalid = Object.keys(state.errors)[0];
  if (firstInvalid) {
    if (state.step === 3) { state = { ...state, step: firstInvalid === 'workshop' ? 1 : 2 }; renderVisit(); }
    const target = form.querySelector('[name="' + firstInvalid + '"]');
    target?.focus();
    document.querySelector('[data-form-status]').textContent = 'Check the highlighted details before continuing.';
  }
});
renderVisit();

parent.postMessage({type:'sg-preview-ready',id},'*');
} catch (error) { reportPreviewError(error); }
})();
LICENSE독립 구현
파일 저장

MIT License

Copyright (c) 2026 StyleGallery contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

This notice covers only the independently authored StyleGallery learning
example. The observed 21st source's reuse license remains unresolved.
NOTES.md독립 구현
파일 저장

# Gather · A plan in three steps

Independent observation study. Domain: website consumer / Design Engineering. This is an unaffiliated learning example.

A single retained draft survives step navigation. The current step drives heading, progress, active fields and actions; validation blocks advance and routes focus to the first invalid field.

## Observation and original-source boundary

Observed public listing: [Multi-Step Form](https://21st.dev/@ravikatiyar162/components/multi-step-form). Observed demo URL: https://cdn.21st.dev/ravikatiyar162/multi-step-form/default/bundle.1759059265422.html.

The observed form starts at step 2 of 3, exposes four fields, and retains values after Back and Next. Captured first and third steps are empty; original validation and final completion were not established.

Original source acquired: false. Original source acquisition status: unresolved. The supplied Stage 3 record says: 게시자 license 빈값; 정확한 MultiStepForm 원본 라이선스 미확인; 같은 username GitHub 공개 경로 404. That status is preserved, not re-investigated or replaced by this implementation's license.

The studio-visit scenario, artwork, complete first and review steps, field validation, error focus, sample values and local completion state are new learning features. No booking is sent or persisted.

The new copy, artwork, code and documentation are covered by the bundled MIT notice. No original source file, asset, distinctive copy, screenshot or brand is included. The evidence revision does not grant a license for the observed original.

## Evidence identity

sourceRevision: ef67d8d8ca77001746ed08ed15d2647c051b503ef2bd50af89cb43050dc8926b

The revision is SHA-256 of the JSON sourceEvidence object in the manifest. It binds the complete Stage 3 index and SceneBook hashes, this record's observation, and its named PNG and metadata hashes. It identifies actual research evidence, not an original Git checkout or a runtime result.

- Stage 3 index: artifacts/full-import/21st-author-sources/stage3/index.json, SHA-256 a9bd0da9712ea0a24e26b3612bfc87a09e13ee5cbb318eb97fe375d8c18d8c08.
- SceneBook: artifacts/full-import/21st-author-sources/stage3/SCENEBOOK.md, SHA-256 140b65a2248332fd415c9a4f42879cc060004f290ea83232af68558f2011e564.
- Relevant frame names: form-initial, form-country-options, form-filled, form-after-back, form-after-next, form-third-step-settled.

The initial PNG for this record was directly viewed during implementation as explicitly requested. Other states follow the Stage 3 transcription; their bytes and metadata hashes were checked, not claimed as newly observed interactions.

## Running and editing

Open index.html directly. The page contains its CSS, JavaScript and original SVG artwork and makes no outside requests. A content-security policy restricts runtime content to inline script/styles and data/blob images. There is no browser storage or remote write.

[styles.css](styles.css) and [script.js](script.js) are exact reference copies of the inline code. To edit a downloaded page, change its inline code or create your own build step. Editing only those companion copies does not change index.html. For repository generation, use StyleGallery/site as the working directory. The authoring modules are vendor/21st-studies/scenes.mjs and models.mjs; run node scripts/build-21st-studies.mjs there. Canonical Markdown lives in the parent StyleGallery checkout, so a binding such as motion/accessible-motion.md resolves as ../motion/accessible-motion.md from the site directory. Generation needs that parent corpus and the private research packet; running the download does not. A rebuild overwrites generated files.

Initial state: step 2 of 3; Print workshop retained from the first step; other required fields empty; one guest; no submission or persistence.

## Interaction and sizing contract

The initial composition targets 640 × 420 CSS pixels. Step changes are immediate in every motion preference. The two field columns become one at narrow widths, and the document owns scrolling. Back retains the draft; invalid advance focuses an associated error field; valid advance focuses the step heading. Completion prepares only a local plan. No horizontal pan or wheel interception is used. These are implementation contracts to check in the rendered page, not conformance results.

## Verification boundary

The generator verifies evidence bytes against recorded hashes, three-ID membership, unresolved source status, canonical Markdown anchors and hashes, named authored functions, JavaScript parsing, semantic landmarks, ID/label relationships, inline-copy equality, and forbidden runtime destinations/storage. This record's model checks cover required fields, calendar validity, retained draft, local completion and edit return. These are source/model checks, not rendered interaction checks.

[KNOWLEDGE.json](KNOWLEDGE.json) holds actionable checks with status not_executed. runtime_verified: false. Browser interaction, full keyboard paths, screen-reader output, comfort and performance remain unverified. In the site checkout, research/full-import/preview-posters.md is the central SG initial-render report, artifacts/full-import/preview-posters/index.json owns capture receipts, and artifacts/full-import/preview-posters/visual-review.json owns frame observations. These private reports are not bundled in the download. Initial-render evidence does not change the interaction boundary.

consumer_reference: consumer-reference/agent-native/registry.json
KNOWLEDGE.json독립 구현
파일 저장

[
  {
    "id": "21st-576ca9a8dddb-study-0",
    "source": "design-engineering/state-management/recipes/multi-step-form.md",
    "anchor": "ownership-and-event-flow",
    "sourceSha256": "b6d2497c71d5554bb53b2d801ca22049d1d0bea9baf2fd0ca48042e7f927457e",
    "title": "Gather · A plan in three steps · updateVisit",
    "principle": "The flow owns the entire draft and current step, so hiding a fieldset does not discard its values.",
    "application": "A single retained draft survives step navigation. The current step drives heading, progress, active fields and actions; validation blocks advance and routes focus to the first invalid field. This binding describes the authored study; it is not verification of the original.",
    "code": [
      {
        "file": "vendor/21st-studies/records/21st-576ca9a8dddb/script.js",
        "symbol": "updateVisit",
        "explanation": "This named function is part of the exact inline runtime in the downloadable HTML. codeFiles records the matching authored source hash."
      }
    ],
    "checks": [
      {
        "id": "21st-576ca9a8dddb-0-updateVisit",
        "action": "Use Try a sample plan, choose Back, select Clay study, then advance and review. Use Back once more.",
        "expected": "The name, date, time and guest count remain; the review shows Clay study. Returning to details keeps the same entered draft.",
        "status": "not_executed"
      }
    ],
    "runtime_verified": false
  },
  {
    "id": "21st-576ca9a8dddb-study-1",
    "source": "design-engineering/state-management/recipes/multi-step-form.md",
    "anchor": "platform-specific-guidance",
    "sourceSha256": "b6d2497c71d5554bb53b2d801ca22049d1d0bea9baf2fd0ca48042e7f927457e",
    "title": "Gather · A plan in three steps · renderVisit",
    "principle": "Step context and first-invalid-field focus follow the same accepted step transition as the progress indicator.",
    "application": "A single retained draft survives step navigation. The current step drives heading, progress, active fields and actions; validation blocks advance and routes focus to the first invalid field. This binding describes the authored study; it is not verification of the original.",
    "code": [
      {
        "file": "vendor/21st-studies/records/21st-576ca9a8dddb/script.js",
        "symbol": "renderVisit",
        "explanation": "This named function is part of the exact inline runtime in the downloadable HTML. codeFiles records the matching authored source hash."
      }
    ],
    "checks": [
      {
        "id": "21st-576ca9a8dddb-1-renderVisit",
        "action": "With the required details blank, activate Review plan. Fill them and advance. Then save the plan and choose Edit plan.",
        "expected": "Blank values block advance and focus the first invalid field with an associated error. Valid advance focuses the step heading. Completion is local, and Edit plan returns to retained details.",
        "status": "not_executed"
      }
    ],
    "runtime_verified": false
  },
  {
    "id": "21st-576ca9a8dddb-study-2",
    "source": "design-engineering/component-contract.md",
    "anchor": "contract-template",
    "sourceSha256": "79e496160e28ab26397d2c5b68075fd14c732d13002ff6c6e41c031d86cac039",
    "title": "Gather · A plan in three steps · renderVisitErrors",
    "principle": "Validity, draft lifetime and completion meaning are explicit; a local demo acknowledgement is not a booking or persistence response.",
    "application": "A single retained draft survives step navigation. The current step drives heading, progress, active fields and actions; validation blocks advance and routes focus to the first invalid field. This binding describes the authored study; it is not verification of the original.",
    "code": [
      {
        "file": "vendor/21st-studies/records/21st-576ca9a8dddb/script.js",
        "symbol": "renderVisitErrors",
        "explanation": "This named function is part of the exact inline runtime in the downloadable HTML. codeFiles records the matching authored source hash."
      }
    ],
    "checks": [
      {
        "id": "21st-576ca9a8dddb-2-renderVisitErrors",
        "action": "Enter a long name, switch steps, and complete the flow at 320px with reduced motion enabled.",
        "expected": "The summary wraps the name, actions remain reachable, no movement is needed, and the page says that nothing is booked. This is a proposed check, not execution evidence.",
        "status": "not_executed"
      }
    ],
    "runtime_verified": false
  }
]
RETRIEVAL-LOG.md독립 구현
파일 저장

# Retrieval log

Task: create exactly three independent runnable learning examples from the Stage 3 public observation packet; preserve IDs and unresolved original-source acquisition.

1. Root AGENTS.md and README.md: domain ownership and guided local retrieval.
2. DOMAINS.md and GOVERNANCE.md: product-layer adaptation, generated artifacts, evidence separation.
3. SG discover: compiler configured at revision 6deb1970c80ed113b90be8d6ca617616bbe336c4. SG workflow read; SHA-256 0318a99707293af423e52b99b40cf1d87d9c7725e3ab06c5a231e3946f2bebb9. The provided workflow snapshot carries the same contract.
4. artifacts/full-import/21st-author-sources/stage3/SCENEBOOK.md and artifacts/full-import/21st-author-sources/stage3/index.json: primary implementation handoff. No original source code was inspected or copied.
5. design-engineering/index.md → decision-tree.md → component-contract.md → state-management/patterns/single-owner.md, state-management/recipes/multi-step-form.md and native-interaction-contracts.md: state, draft and disclosure ownership.
6. motion/index.md → accessible-motion.md and observed-choreography.md: stationary path and observation limits. Scroll-specific pin and choreography contracts were rejected because these three observations have no scroll choreography.
7. Opened atc-initial.png, sidebar-initial.png and form-initial.png through local image inspection because the delegated task explicitly requested actual PNG review. The authored output does not contain those images.

KNOWLEDGE.json contains exact current-authoring Markdown paths, real anchors and SHA-256 values for the applied sections. No design-terminology records are used. Named external sources inside the canonical guides were not re-checked in this lane; only local guidance and the supplied observation evidence were checked.

No canonical corpus edits, original 21st source-pack edits, shared UI edits, source-acquisition changes or new worker sessions belong to this lane.

consumer_reference: consumer-reference/agent-native/registry.json