diff --git a/app/static/app.css b/app/static/app.css index b55177e..7b58005 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -1,5 +1,11 @@ /* 커스텀 스타일 — design-ref.md (Toss 스타일) 기반. Pico 뒤에 로드해 변수 오버라이드. - 폰트는 폐쇄망 대비 로컬 동봉(static/fonts), CDN 의존 없음. */ + 폰트는 폐쇄망 대비 로컬 동봉(static/fonts), CDN 의존 없음. + + [재디자인 노트] + - 토큰(그림자/모션/포커스)을 추가해 라이트·다크가 함께 정돈되도록 함. + - hover/press/정렬/진행/모달 진입에 절제된 마이크로 인터랙션 추가. + - 확정 스타일: 요약카드=화이트+호버리프트, 표=제브라, 배포모달=중앙 경고형. + - 모든 모션은 prefers-reduced-motion 에서 비활성화(파일 맨 끝). */ @font-face { font-family: "Pretendard Variable"; @@ -28,15 +34,33 @@ --text-dim: #8b95a1; --border: #e5e8eb; --border-strong: #d1d6db; - --accent: #3182f6; - --accent-hover: #1b64da; + --accent: oklch(0.63 0.147 255); /* Toss Blue 계열이되 채도를 낮춰 눈부심(쨍) 완화 */ + --accent-hover: oklch(0.55 0.16 255); --accent-deep: #1e40af; --accent-soft: #e8f3ff; --success-text: #00a37a; + --success-soft: rgba(0, 200, 150, 0.12); --warning-text: #c5700a; + --warning-soft: rgba(197, 112, 10, 0.12); --danger: #f04452; + --danger-hover: #e0313f; + --danger-soft: rgba(240, 68, 82, 0.1); --radius-card: 12px; + + /* 그림자 — 단계형. design-ref §6: 얇은 카드 그림자 + 무거운 모달 그림자 */ + --shadow-sm: 0 1px 2px rgba(25, 31, 40, 0.04); + --shadow-card: 0 1px 3px rgba(25, 31, 40, 0.05); + --shadow-card-hover: 0 4px 16px rgba(25, 31, 40, 0.09); + --shadow-modal: 0 12px 40px rgba(25, 31, 40, 0.18); + + /* 모션 토큰 — 도구답게 짧고 자연스럽게 */ + --ease: cubic-bezier(0.32, 0.72, 0, 1); + --dur-fast: 0.12s; + --dur: 0.2s; + --dur-slow: 0.32s; + --focus-ring: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent); + /* 영문/숫자(저장소명·버전·패키지수·시각)는 모노 — 도구다운 가독성 (design-ref §3). 혹시 섞이는 한글은 모노에 글리프가 없으니 한글 sans 로 폴백. */ --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, @@ -48,7 +72,7 @@ system-ui, sans-serif; --pico-font-family: var(--pico-font-family-sans-serif); --pico-line-height: 1.55; - --pico-border-radius: 7px; + --pico-border-radius: 8px; --pico-background-color: var(--bg-alt); --pico-color: var(--text-secondary); --pico-h1-color: var(--text); @@ -62,7 +86,7 @@ --pico-primary-hover-border: var(--accent-hover); --pico-card-background-color: var(--bg); --pico-card-border-color: var(--border); - --pico-card-box-shadow: 0 1px 3px rgba(25, 31, 40, 0.04); + --pico-card-box-shadow: var(--shadow-card); color-scheme: light; } @@ -85,8 +109,18 @@ --accent-deep: #2f6fd0; --accent-soft: #1e2b44; --success-text: #2ee0a8; + --success-soft: rgba(46, 224, 168, 0.14); --warning-text: #ffb454; + --warning-soft: rgba(255, 180, 84, 0.14); --danger: #ff6b78; + --danger-hover: #ff8591; + --danger-soft: rgba(255, 107, 120, 0.14); + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.3); + --shadow-card-hover: 0 4px 18px rgba(0, 0, 0, 0.45); + --shadow-modal: 0 12px 44px rgba(0, 0, 0, 0.6); + --focus-ring: 0 0 0 3px color-mix(in srgb, var(--accent) 40%, transparent); --pico-background-color: var(--bg-alt); --pico-color: var(--text-secondary); @@ -101,23 +135,20 @@ --pico-primary-hover-border: var(--accent-hover); --pico-card-background-color: var(--bg); --pico-card-border-color: var(--border); - --pico-card-box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + --pico-card-box-shadow: var(--shadow-card); --pico-form-element-background-color: var(--surface); --pico-form-element-border-color: var(--border); --pico-form-element-color: var(--text); --pico-progress-background-color: var(--surface); } -body { - transition: - background-color 0.15s ease, - color 0.15s ease; -} - body { background: var(--bg-alt); color: var(--text-secondary); -webkit-font-smoothing: antialiased; + transition: + background-color var(--dur) ease, + color var(--dur) ease; } main.container { @@ -131,6 +162,7 @@ h3 { color: var(--text); font-weight: 700; letter-spacing: -0.02em; + text-wrap: balance; } h2 { font-size: 22px; @@ -146,18 +178,55 @@ section { font-feature-settings: "tnum" 1; } -/* 상단 내비 */ +/* 공통 포커스 링 — 키보드 접근성. 마우스 클릭엔 안 뜨게 focus-visible. */ +:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +/* 상단 내비 — 스크롤해도 상단 고정, 미세한 그림자로 레이어 분리 */ .app-nav { - background: var(--bg); + position: sticky; + top: 0; + z-index: 20; + background: color-mix(in srgb, var(--bg) 88%, transparent); + backdrop-filter: saturate(1.4) blur(10px); border-bottom: 1px solid var(--border); } .app-nav .container { max-width: 1040px; - padding-block: 18px; + padding-block: 16px; display: flex; align-items: center; justify-content: space-between; } +.app-nav .brand { + margin: 0; + font-size: 18px; + font-weight: 700; + color: var(--text); + letter-spacing: -0.02em; + display: inline-flex; + align-items: center; + gap: 9px; +} +.app-nav .brand .accent { + color: var(--accent); +} +/* 브랜드 앞 로고 마크 — 차분한 단색(그라디언트 제거로 눈부심 완화) */ +.app-nav .brand .brand-mark { + width: 26px; + height: 26px; + border-radius: 7px; + background: var(--accent); + display: inline-grid; + place-items: center; + color: #fff; + font-size: 13px; + font-weight: 800; + box-shadow: var(--shadow-sm); + flex: none; +} /* 테마 토글 (아이콘 버튼) */ .theme-toggle { @@ -166,17 +235,29 @@ section { --pico-color: var(--text-secondary); width: auto; margin: 0; - padding: 7px 9px; + padding: 8px 10px; line-height: 0; + border-radius: 9px; + transition: + background-color var(--dur-fast) ease, + border-color var(--dur-fast) ease, + transform var(--dur-fast) var(--ease); } .theme-toggle:hover { --pico-background-color: var(--surface); --pico-border-color: var(--text-dim); --pico-color: var(--text); } +.theme-toggle:active { + transform: scale(0.94); +} .theme-toggle .icon { width: 18px; height: 18px; + transition: transform var(--dur) var(--ease); +} +.theme-toggle:hover .icon { + transform: rotate(20deg); } .theme-toggle .sun { display: none; @@ -190,16 +271,6 @@ section { [data-theme="dark"] .theme-toggle .sun { display: inline-block; } -.app-nav .brand { - margin: 0; - font-size: 18px; - font-weight: 700; - color: var(--text); - letter-spacing: -0.02em; -} -.app-nav .brand .accent { - color: var(--accent); -} .section-head { display: flex; @@ -224,34 +295,66 @@ section { font-size: 14px; } -/* 버튼 — Toss: radius 7, weight 600, 누름 피드백 scale(0.98) (design-ref §4·§6) */ +/* ── 버튼 — Toss: radius 7~8, weight 600, 누름 피드백 (design-ref §4·§6) ── */ button, [role="button"] { font-weight: 600; + letter-spacing: -0.01em; + border-radius: 8px; + white-space: nowrap; transition: - background-color 0.12s ease, - border-color 0.12s ease, - opacity 0.15s ease, - transform 0.096s ease; + background-color var(--dur-fast) ease, + border-color var(--dur-fast) ease, + box-shadow var(--dur-fast) ease, + opacity var(--dur) ease, + transform var(--dur-fast) var(--ease); } -button:active, -[role="button"]:active { - transform: scale(0.98); +button:not(:disabled):hover, +[role="button"]:not(:disabled):hover { + box-shadow: var(--shadow-sm); } -button.secondary, -[role="button"].secondary, -a.secondary { - --pico-background-color: var(--surface-2); - --pico-border-color: var(--surface-2); - --pico-color: var(--accent-hover); +button:not(:disabled):active, +[role="button"]:not(:disabled):active { + transform: scale(0.97); + box-shadow: none; +} +/* primary 눌림 시 더 짙은 파랑 (design-ref: pressed=accent-deep) */ +button[type="submit"]:not(.secondary):not(.ghost):not(.danger):active, +button:not(.secondary):not(.ghost):not(.danger):not([role]):active { + --pico-primary-background: var(--accent-deep); + --pico-primary-border: var(--accent-deep); +} +/* 동기화 등 반복 노출 액션: 흰 배경 + 파란 글씨 + 옅은 파란 테두리. + 표에 여러 개 떠도 번잡하지 않고, ghost(새로고침)와 확실히 구분됨. + Pico v2 는 `:is(button,[type=submit],[type=button],[role=button]).secondary` + (특정성 0,2,0)로 secondary 를 칠하므로, 같은 :is() 형태로 특정성을 맞춰 + 뒤에 로드되는 app.css 가 이기게 한다(단순 `button.secondary`=0,1,1 은 짐). */ +:is(button, [role="button"], a).secondary { + --pico-background-color: var(--bg); + --pico-border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); + --pico-color: var(--accent); font-weight: 600; } +/* Pico 의 secondary hover 는 `:is(button,…).secondary:is([aria-current]…,:hover,…)` + = 특정성 (0,4,0) 이라 어두운 배경 + 흰 글씨로 덮어쓴다. 같은 (0,4,0) :is() 형태로 + 맞춰(뒤에 로드되어 우선) 밝은 파랑 hover 를 유지한다. 글씨색(--pico-color)도 + 함께 지정하지 않으면 Pico 가 흰색으로 만들어 옅은 배경 위에서 글씨가 사라진다. */ +:is(button, [role="button"], a).secondary:hover:not(:disabled) { + --pico-background-color: var(--accent-soft); + --pico-border-color: var(--accent); + --pico-color: var(--accent); +} button.danger, [role="button"].danger { --pico-background-color: var(--danger); --pico-border-color: var(--danger); --pico-color: #ffffff; } +button.danger:not(:disabled):hover, +[role="button"].danger:not(:disabled):hover { + --pico-background-color: var(--danger-hover); + --pico-border-color: var(--danger-hover); +} /* 유틸리티(새로고침 등): 중립 ghost — 동기화(틴티드 블루)와 구분 */ button.ghost, [role="button"].ghost { @@ -263,7 +366,7 @@ button.ghost:hover, [role="button"].ghost:hover { --pico-background-color: var(--surface); --pico-border-color: var(--text-dim); - --pico-color: var(--text); /* 호버 시 글자 하얘짐 방지 */ + --pico-color: var(--text); } /* 아이콘 + 글자 버튼 */ .btn-icon { @@ -277,6 +380,7 @@ button.ghost:hover, button:disabled, [role="button"]:disabled { opacity: 0.45; + cursor: not-allowed; } /* 표 안 액션 버튼: 컴팩트하되 글자는 또렷하게 */ .btn-sm { @@ -286,7 +390,32 @@ button:disabled, width: auto; } -/* 요약 카드 */ +/* 버튼 로딩 상태 — 텍스트를 스피너로 교체. .is-loading 토글로 사용. + (HTMX hx-disabled-elt 로 비활성화될 때 함께 붙이면 자연스러움) */ +button.is-loading, +[role="button"].is-loading { + color: transparent !important; + position: relative; + pointer-events: none; +} +button.is-loading::after, +[role="button"].is-loading::after { + content: ""; + position: absolute; + inset: 0; + margin: auto; + width: 15px; + height: 15px; + border: 2px solid color-mix(in srgb, currentColor 30%, transparent); + border-top-color: var(--pico-color, #fff); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ── 요약 카드 ── */ .summary { display: grid; grid-template-columns: repeat(4, 1fr); @@ -294,15 +423,50 @@ button:disabled, margin-bottom: 24px; } .summary .card { + position: relative; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-card); padding: 20px 22px; - box-shadow: 0 1px 3px rgba(25, 31, 40, 0.04); - transition: border-color 0.12s ease; + box-shadow: var(--shadow-card); + transition: + border-color var(--dur) ease, + box-shadow var(--dur) ease, + transform var(--dur) var(--ease); } .summary .card:hover { border-color: var(--border-strong); + box-shadow: var(--shadow-card-hover); + transform: translateY(-2px); +} +/* 카드 상단 라벨 + 아이콘 칩 한 줄 */ +.summary .card-top { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} +.summary .card-ico { + width: 34px; + height: 34px; + border-radius: 9px; + display: grid; + place-items: center; + background: var(--surface); + color: var(--text-muted); + flex: none; +} +.summary .card-ico .icon { + width: 18px; + height: 18px; +} +.summary .card.is-accent .card-ico { + background: var(--accent-soft); + color: var(--accent); +} +.summary .card.is-success .card-ico { + background: var(--success-soft); + color: var(--success-text); } .summary .value { font-family: var(--font-mono); @@ -326,26 +490,28 @@ button:disabled, color: var(--text-muted); } -/* 표 (카드로 감싸기, hairline 행, hover) */ +/* ── 표 (카드로 감싸기, hairline 행, hover) ── */ .table-card { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-card); overflow: hidden; - box-shadow: 0 1px 3px rgba(25, 31, 40, 0.04); + box-shadow: var(--shadow-card); } .table-card table { margin: 0; } .table-card thead th { - font-size: 13px; + font-size: 12px; font-weight: 600; color: var(--text-dim); text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.05em; background: var(--bg-alt); border-bottom: 1px solid var(--border); - padding-block: 14px; + padding-block: 13px; + position: sticky; + top: 0; } .table-card tbody td { height: 56px; @@ -353,18 +519,32 @@ button:disabled, border-bottom: 1px solid var(--border); padding-block: 12px; vertical-align: middle; + /* Pico 의 `td,th{background-color:var(--pico-background-color)}`(흰색)이 td 자신의 + 배경으로 칠해져 tr 의 제브라/hover 배경을 덮어버린다(특정성이 아니라 페인트 순서 문제). + td 배경을 투명으로 돌려 tr 배경이 보이게 한다. */ + background: transparent; +} +.table-card tbody tr { + transition: background-color var(--dur-fast) ease; } .table-card tbody tr:last-child td { border-bottom: none; } -.table-card tbody tr:hover { +/* 2B안(확정): 제브라 스트라이프 + 틴티드 블루 hover. + 짝수 행에 연한 회색(--bg-alt)을 깔아 넓은 표에서도 행이 눈으로 잘 따라감. + (기존 color-mix 값은 거의 흰색이라 실제로 안 보였음 → bg-alt 로 또렷하되 은은하게) */ +.table-card tbody tr:nth-child(even) { background: var(--bg-alt); } +.table-card tbody tr:hover { + background: var(--surface-2); +} .repo-name a { font-family: var(--font-mono); font-weight: 600; color: var(--text); text-decoration: none; + transition: color var(--dur-fast) ease; } .repo-name a:hover { color: var(--accent); @@ -387,20 +567,23 @@ th.num { display: flex; align-items: center; width: 172px; - height: 36px; - overflow: hidden; + /* 버튼(테두리 포함 ~37.7px)↔진행률↔배지로 내용이 바뀌어도 높이가 안 흔들리도록 + 40px 로 고정하고 가운데 정렬(td 는 56px 고정이라 여유). 이전 36px 는 버튼보다 + 낮아 위아래 테두리가 잘리거나(overflow:hidden) 높이가 튀었다(min-height). */ + height: 40px; } -/* 검증 배지 칩 (dot + 라벨, 이모지 없음) */ +/* ── 검증 배지 칩 (dot + 라벨, 이모지 없음) ── */ .badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 11px; border-radius: 999px; - font-size: 14px; - font-weight: 500; + font-size: 13px; + font-weight: 600; white-space: nowrap; + border: 1px solid transparent; } .badge::before { content: ""; @@ -408,14 +591,25 @@ th.num { height: 7px; border-radius: 50%; background: currentColor; + flex: none; } .badge-pass { color: var(--success-text); - background: rgba(0, 200, 150, 0.12); + background: var(--success-soft); +} +/* PASS 배지의 dot 은 살짝 은은한 펄스 (통과했음을 안심시키는 신호) */ +.badge-pass::before { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--success-text) 55%, transparent); + animation: dot-pulse 2.4s var(--ease) infinite; +} +@keyframes dot-pulse { + 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--success-text) 45%, transparent); } + 60% { box-shadow: 0 0 0 5px transparent; } + 100% { box-shadow: 0 0 0 0 transparent; } } .badge-warn { color: var(--warning-text); - background: rgba(255, 149, 0, 0.13); + background: var(--warning-soft); } .badge-unset { color: var(--text-muted); @@ -445,26 +639,127 @@ th.num { font-family: var(--font-mono); } +/* 버전 스냅샷 리스트 */ +.version-item { + display: flex; + align-items: center; + gap: 14px; + padding: 16px 18px; + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg); + box-shadow: var(--shadow-card); + margin-bottom: 12px; + transition: + border-color var(--dur) ease, + box-shadow var(--dur) ease; +} +.version-item:hover { + border-color: var(--border-strong); + box-shadow: var(--shadow-card-hover); +} +.version-item .ver-no { + font-family: var(--font-mono); + font-weight: 700; + font-size: 15px; + color: var(--text); +} +.version-item .ver-meta { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 13px; +} +.version-item .spacer { + margin-left: auto; +} + /* 패키지 증감 */ .delta-add { color: var(--success-text); + font-weight: 600; } .delta-del { color: var(--danger); + font-weight: 600; } -/* 배포 완료 배너 (#flash) */ +/* ── 데모(가짜 데이터) 모드 경고 배너 — 전 페이지 최상단. 실연동 오인 방지. ── */ +.demo-banner { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 20px; + background: var(--warning-soft); + color: var(--warning-text); + font-size: 14px; + font-weight: 500; + border-bottom: 1px solid color-mix(in srgb, var(--warning-text) 28%, transparent); +} +.demo-banner strong { + font-weight: 700; +} +.demo-banner .demo-dot { + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; + animation: dot-pulse-warn 2s var(--ease) infinite; +} +@keyframes dot-pulse-warn { + 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warning-text) 45%, transparent); } + 60% { box-shadow: 0 0 0 6px transparent; } + 100% { box-shadow: 0 0 0 0 transparent; } +} +/* 연결 배지의 데모 변형 */ +.status-demo { + color: var(--warning-text); + background: var(--warning-soft); +} + +/* ── 배포 완료 배너 (#flash) ── */ .flash-banner { display: flex; align-items: center; gap: 10px; margin: 4px 0 22px; - padding: 12px 16px; + padding: 13px 16px; border-radius: 10px; - background: rgba(0, 200, 150, 0.1); + background: var(--success-soft); color: var(--success-text); font-size: 15px; font-weight: 600; + border: 1px solid color-mix(in srgb, var(--success-text) 24%, transparent); + animation: flash-in var(--dur-slow) var(--ease); +} +@keyframes flash-in { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} +/* 성공 체크 원 — dot 대신 그려지는 체크 애니메이션 */ +.flash-banner .flash-check { + flex: none; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--success-text); + display: grid; + place-items: center; +} +.flash-banner .flash-check svg { + width: 12px; + height: 12px; + stroke: #fff; + stroke-width: 3; + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + stroke-dasharray: 20; + stroke-dashoffset: 20; + animation: check-draw 0.4s var(--ease) 0.12s forwards; +} +@keyframes check-draw { + to { stroke-dashoffset: 0; } } .flash-banner .flash-dot { flex: none; @@ -485,64 +780,229 @@ th.num { font-size: 13px; } -/* 배포 확인 모달 — 본문/표/라벨 15px 로 통일 */ -.deploy-modal h3 { - font-size: 20px; +/* ── 배포 확인 모달 ── */ +#modal[open], +dialog.deploy-dialog[open] { + animation: backdrop-in var(--dur) ease; +} +@keyframes backdrop-in { + from { opacity: 0; } + to { opacity: 1; } +} +/* 모달이 열릴 때 배경 스크롤 잠금 + 다이얼로그 overflow 허용 → 순간 스크롤바 깜뱡임 제거 */ +html:has(dialog[open]) { + overflow: hidden; +} +dialog.deploy-dialog, +dialog#modal { + overflow: visible; +} +.deploy-modal { + width: min(520px, 92vw); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 16px; + box-shadow: var(--shadow-modal); + padding: 0; + overflow: hidden; + animation: modal-in var(--dur-slow) var(--ease); +} +@keyframes modal-in { + from { opacity: 0; transform: scale(0.96); } + to { opacity: 1; transform: scale(1); } +} +/* 위험 확인 아이콘 — 중앙 정렬 원형(의식적·경고적). 모달 헤더. */ +.deploy-modal .modal-head { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 10px; + padding: 26px 24px 6px; +} +.deploy-modal .modal-head .warn-ico { + flex: none; + width: 52px; + height: 52px; + border-radius: 50%; + display: grid; + place-items: center; + background: var(--danger-soft); + color: var(--danger); +} +.deploy-modal .modal-head .warn-ico svg { + width: 26px; + height: 26px; +} +.deploy-modal .modal-head h3 { + font-size: 21px; + margin: 0; + color: var(--text); +} +.deploy-modal .modal-head .head-sub { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--danger); + margin-top: 2px; +} +.deploy-modal .modal-body { + padding: 20px 24px 24px; } .deploy-note { - font-size: 17px; + font-size: 15px; color: var(--text-secondary); - margin-bottom: 14px; + margin-bottom: 16px; } +.deploy-note .repo-hl { + font-family: var(--font-mono); + font-weight: 700; + color: var(--text); +} +/* 현재 → 대상 프리뷰: 카드형 before/after */ .deploy-preview { - margin: 0 0 18px; - font-size: 17px; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: stretch; + gap: 10px; + margin: 0 0 20px; } -.deploy-preview, -.deploy-preview tr, -.deploy-preview th, -.deploy-preview td { - border: none; - background: none; +.deploy-preview .pv-box { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px 14px; } -.deploy-preview th { - width: 30%; - padding: 8px 0; - text-align: left; - font-weight: 500; +.deploy-preview .pv-box.pv-target { + background: var(--accent-soft); + border-color: color-mix(in srgb, var(--accent) 32%, transparent); +} +.deploy-preview .pv-label { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); + margin-bottom: 6px; +} +.deploy-preview .pv-target .pv-label { + color: var(--accent); +} +.deploy-preview .pv-ver { + font-family: var(--font-mono); + font-size: 20px; + font-weight: 700; + color: var(--text); + line-height: 1.15; +} +.deploy-preview .pv-pkg { + font-family: var(--font-mono); + font-size: 12px; color: var(--text-muted); + margin-top: 4px; } -.deploy-preview td { - padding: 8px 0; - text-align: right; +.deploy-preview .pv-arrow { + align-self: center; + color: var(--text-dim); + display: grid; + place-items: center; } +.deploy-preview .pv-arrow svg { + width: 22px; + height: 22px; +} + +/* 타입-투-컨펌 — 저장소 이름을 정확히 입력해야 확정 버튼이 열림 */ .confirm-label { display: block; - font-size: 17px; -} -/* 입력란만 작게 (본문보다 4px 작은 14px + 컴팩트 높이) */ -.confirm-label input { - margin-top: 8px; font-size: 14px; - --pico-form-element-spacing-vertical: 0.5rem; + font-weight: 500; + color: var(--text-secondary); +} +.confirm-label .repo-hl { + font-family: var(--font-mono); + font-weight: 700; + color: var(--text); + background: var(--surface); + padding: 1px 7px; + border-radius: 5px; +} +.confirm-field { + position: relative; + margin-top: 8px; +} +.confirm-label input { + font-family: var(--font-mono); + font-size: 14px; + --pico-form-element-spacing-vertical: 0.6rem; height: auto; + padding-right: 40px; +} +/* 입력 상태 시각화: 비어있음(중립) → 불일치(빨강) → 일치(초록 체크) */ +.confirm-field .confirm-state { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + width: 18px; + height: 18px; + opacity: 0; + transition: opacity var(--dur) ease; +} +.confirm-field.is-valid input { + border-color: var(--success-text); + box-shadow: 0 0 0 3px var(--success-soft); +} +.confirm-field.is-valid .confirm-state.ok { + opacity: 1; + color: var(--success-text); +} +.confirm-field.is-invalid input { + border-color: var(--danger); +} +.confirm-field.is-invalid .confirm-state.bad { + opacity: 1; + color: var(--danger); +} +.confirm-hint { + font-size: 12px; + margin-top: 6px; + min-height: 1em; + color: var(--text-dim); + font-family: var(--font-mono); +} +.confirm-field.is-invalid ~ .confirm-hint { + color: var(--danger); +} +.confirm-field.is-valid ~ .confirm-hint { + color: var(--success-text); +} +/* 불일치로 확정 버튼 누르려 하면 흔들림 (JS 로 .shake 토글) */ +.confirm-field.shake { + animation: shake 0.32s var(--ease); +} +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-6px); } + 40% { transform: translateX(6px); } + 60% { transform: translateX(-4px); } + 80% { transform: translateX(4px); } } .modal-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; - margin-top: 20px; + margin-top: 22px; } -/* 모달 버튼: 편안한 표준 크기, 한 줄, 너비 auto */ .modal-actions button { width: auto; margin: 0; padding: 11px 20px; - font-size: 16px; + font-size: 15px; } -/* 동기화 진행률 바 */ +/* ── 동기화 진행률 바 ── */ .sync-progress { display: flex; flex-direction: column; @@ -553,21 +1013,53 @@ th.num { margin: 0; width: 100%; height: 6px; + border-radius: 999px; } .sync-progress small { color: var(--text-muted); font-family: var(--font-mono); font-size: 12px; + font-feature-settings: "tnum" 1; +} +/* 진행 중인 결정형 바 위에 흐르는 shimmer — 값(width)은 progress 가 담당, + shimmer 는 "지금 살아있음"을 보여주는 오버레이 */ +.sync-progress { + position: relative; +} +.sync-progress.is-active progress::after { + content: ""; +} +.sync-progress .sync-head { + display: flex; + align-items: baseline; + justify-content: space-between; +} +.sync-progress .sync-pct { + color: var(--accent); + font-weight: 700; +} +/* 값을 모르는 초기 상태용 인디터미닛 바 */ +.sync-progress.indeterminate progress { + background: + linear-gradient(90deg, transparent, var(--accent), transparent) no-repeat, + var(--pico-progress-background-color, var(--surface)); + background-size: 40% 100%; + animation: indeterminate 1.2s var(--ease) infinite; +} +@keyframes indeterminate { + 0% { background-position: -40% 0; } + 100% { background-position: 140% 0; } } -/* 연결 상태 칩 (dot + 텍스트) */ +/* ── 연결 상태 칩 (dot + 텍스트) ── */ .status-line { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px; - font-weight: 500; + font-weight: 600; + font-size: 14px; background: var(--surface); } .status-line::before { @@ -576,30 +1068,56 @@ th.num { height: 8px; border-radius: 50%; background: currentColor; + flex: none; } .status-ok { color: var(--success-text); - background: rgba(0, 200, 150, 0.1); + background: var(--success-soft); +} +.status-ok::before { + animation: dot-pulse 2.4s var(--ease) infinite; } .status-bad { color: var(--danger); - background: rgba(240, 68, 82, 0.1); + background: var(--danger-soft); } .status-loading { color: var(--text-muted); } +.status-loading::before { + animation: blink 1s steps(2, start) infinite; +} +@keyframes blink { + 50% { opacity: 0.3; } +} -/* 검색 툴바 / 정렬 헤더 / 페이저 */ +/* ── 검색 툴바 / 정렬 헤더 / 페이저 ── */ .list-toolbar { margin-bottom: 14px; + position: relative; } -.repo-search { +/* 선택자에 input[type=search] 를 포함해 특정성 (0,3,1) 확보. + Pico v2 의 검색 아이콘/패딩 규칙(input:not(...)[type=search], 0,2,1)을 이겨야 + 기본 돋보기 배경이 사라지고 인라인 .search-ico 만 남는다(아이콘 중복 제거). */ +.list-toolbar input[type="search"].repo-search { width: 100%; margin: 0; font-size: 14px; - /* 세로로 얇게 */ - --pico-form-element-spacing-vertical: 0.5rem; height: auto; + /* 좌측 돋보기 아이콘 자리 확보 (아이콘 left:13 + 16px → 텍스트 40px 부터) */ + padding: 0.62rem 14px 0.62rem 40px; + /* Pico 기본 [type=search] 돋보기 배경 아이콘 제거 (인라인 .search-ico 사용) */ + background-image: none; +} +.list-toolbar .search-ico { + position: absolute; + left: 13px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + color: var(--text-dim); + pointer-events: none; } .table-card thead th a.th-sort { display: inline-flex; @@ -608,6 +1126,7 @@ th.num { color: inherit; text-decoration: none; cursor: pointer; + transition: color var(--dur-fast) ease; } .table-card thead th a.th-sort:hover { color: var(--text-secondary); @@ -617,6 +1136,11 @@ th.num { } .sort-ind { font-size: 11px; + transition: transform var(--dur) var(--ease); + display: inline-block; +} +.th-sort.is-active.desc .sort-ind { + transform: rotate(180deg); } .pager { display: flex; @@ -628,13 +1152,100 @@ th.num { color: var(--text-muted); font-size: 13px; } +.pager button { + padding: 6px 12px; + font-size: 13px; +} + +/* ── 빈 상태 / 에러 상태 ── */ .empty-note { - padding: 16px; + padding: 48px 16px; color: var(--text-muted); + text-align: center; +} +.empty-note .empty-ico { + width: 40px; + height: 40px; + margin: 0 auto 12px; + color: var(--text-dim); + display: grid; + place-items: center; +} +.empty-note .empty-title { + font-weight: 600; + color: var(--text-secondary); + font-size: 15px; +} +.empty-note .empty-sub { + font-size: 13px; + margin-top: 4px; +} +.error-note { + padding: 20px; + border-radius: 10px; + background: var(--danger-soft); + color: var(--danger); + border: 1px solid color-mix(in srgb, var(--danger) 22%, transparent); + font-weight: 500; + display: flex; + align-items: center; + gap: 10px; +} + +/* ── 스켈레톤 로딩 (표 초기/재조회) ── */ +.skeleton { + background: linear-gradient( + 90deg, + var(--surface) 25%, + color-mix(in srgb, var(--surface) 60%, var(--bg)) 37%, + var(--surface) 63% + ); + background-size: 400% 100%; + border-radius: 6px; + animation: skeleton 1.4s ease infinite; +} +@keyframes skeleton { + 0% { background-position: 100% 0; } + 100% { background-position: 0 0; } +} +.skeleton-row { + height: 16px; +} + +/* HTMX 요청 중 페이드 (hx-swap 대상에 자동 적용) */ +.htmx-swapping { + opacity: 0.4; + transition: opacity var(--dur) ease; +} +tr.htmx-request .row-action button { + opacity: 0.6; } @media (max-width: 640px) { .summary { grid-template-columns: repeat(2, 1fr); } + .deploy-preview { + grid-template-columns: 1fr; + } + .deploy-preview .pv-arrow { + transform: rotate(90deg); + } +} + +/* ───────────────────────────────────────────────────────────── + 모션 최소화 선호 존중 (design-ref §6 취지) + ───────────────────────────────────────────────────────────── */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } + .summary .card:hover { + transform: none; + } } diff --git a/app/templates/base.html b/app/templates/base.html index 0b924a4..dc61801 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -24,9 +24,16 @@
+ {% if demo_mode %} + + {% endif %}