Compare commits
5 Commits
b331948491
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 205b44099a | |||
| 2b0b8cb041 | |||
| d04dca2646 | |||
| 24ee55a069 | |||
| 4690166e8d |
@@ -133,6 +133,13 @@ $env:TEST_DATABASE_ENGINE="sqlite"
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
Run pytest with coverage:
|
||||
|
||||
```powershell
|
||||
$env:TEST_DATABASE_ENGINE="sqlite"
|
||||
python -m pytest --cov --cov-report=term-missing
|
||||
```
|
||||
|
||||
Run worker locally:
|
||||
|
||||
```powershell
|
||||
|
||||
BIN
assets/fonts/Vazirmatn.woff2
Normal file
@@ -1,4 +1,6 @@
|
||||
DEBUG=true
|
||||
ENABLE_DJANGO_DEBUG_TOOLBAR=true
|
||||
DJANGO_INTERNAL_IPS=127.0.0.1,localhost,host.docker.internal
|
||||
DJANGO_SECRET_KEY=change-me
|
||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
|
||||
DJANGO_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
|
||||
@@ -634,6 +634,8 @@ Example:
|
||||
| Variable | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `DEBUG` | `true` | Enables debug mode and permissive CORS in local development. |
|
||||
| `ENABLE_DJANGO_DEBUG_TOOLBAR` | `true` when `DEBUG=true` | Enables Django Debug Toolbar for local backend requests. |
|
||||
| `DJANGO_INTERNAL_IPS` | `127.0.0.1,localhost,host.docker.internal` | Internal IP allowlist used by Django Debug Toolbar. |
|
||||
| `DJANGO_SECRET_KEY` | local dev key | Django secret key. Override outside local development. |
|
||||
| `DJANGO_ALLOWED_HOSTS` | `localhost,127.0.0.1,0.0.0.0` | Allowed hosts. |
|
||||
| `DJANGO_CORS_ALLOWED_ORIGINS` | `http://localhost:5173,http://127.0.0.1:5173` | Frontend origins. |
|
||||
@@ -1012,6 +1014,13 @@ cd backend
|
||||
$env:TEST_DATABASE_ENGINE='sqlite'; .\.venv\Scripts\python.exe -m pytest
|
||||
```
|
||||
|
||||
Run backend tests with coverage:
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
$env:TEST_DATABASE_ENGINE='sqlite'; .\.venv\Scripts\python.exe -m pytest --cov --cov-report=term-missing
|
||||
```
|
||||
|
||||
The test suite covers:
|
||||
|
||||
- successful claim
|
||||
|
||||
@@ -10,6 +10,9 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "django-insecure-job-queue-local-dev-key")
|
||||
DEBUG = os.getenv("DEBUG", "true").lower() in {"1", "true", "yes"}
|
||||
ALLOWED_HOSTS = [host.strip() for host in os.getenv("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0").split(",")]
|
||||
ENABLE_DJANGO_DEBUG_TOOLBAR = (
|
||||
DEBUG and os.getenv("ENABLE_DJANGO_DEBUG_TOOLBAR", "true").lower() in {"1", "true", "yes"}
|
||||
)
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"unfold",
|
||||
@@ -25,6 +28,9 @@ INSTALLED_APPS = [
|
||||
"jobs",
|
||||
]
|
||||
|
||||
if ENABLE_DJANGO_DEBUG_TOOLBAR:
|
||||
INSTALLED_APPS.append("debug_toolbar")
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
@@ -36,6 +42,9 @@ MIDDLEWARE = [
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
if ENABLE_DJANGO_DEBUG_TOOLBAR:
|
||||
MIDDLEWARE.insert(2, "debug_toolbar.middleware.DebugToolbarMiddleware")
|
||||
|
||||
ROOT_URLCONF = "config.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
@@ -120,6 +129,21 @@ UNFOLD = {
|
||||
"SITE_SUBHEADER": "Jobs, attempts, leases, and events",
|
||||
}
|
||||
|
||||
INTERNAL_IPS = [
|
||||
ip.strip()
|
||||
for ip in os.getenv("DJANGO_INTERNAL_IPS", "127.0.0.1,localhost,host.docker.internal").split(",")
|
||||
if ip.strip()
|
||||
]
|
||||
|
||||
|
||||
def show_debug_toolbar(_request):
|
||||
return ENABLE_DJANGO_DEBUG_TOOLBAR
|
||||
|
||||
|
||||
DEBUG_TOOLBAR_CONFIG = {
|
||||
"SHOW_TOOLBAR_CALLBACK": show_debug_toolbar,
|
||||
}
|
||||
|
||||
JOB_WORKER_THREADS = int(os.getenv("JOB_WORKER_THREADS", "4"))
|
||||
JOB_WORKER_POLL_INTERVAL_MS = int(os.getenv("JOB_WORKER_POLL_INTERVAL_MS", "500"))
|
||||
JOB_WORKER_LEASE_SECONDS = int(os.getenv("JOB_WORKER_LEASE_SECONDS", "30"))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||
@@ -31,3 +32,6 @@ urlpatterns = [
|
||||
path("api/config/", ConfigAPIView.as_view(), name="config"),
|
||||
path("api/", include("jobs.urls")),
|
||||
]
|
||||
|
||||
if settings.ENABLE_DJANGO_DEBUG_TOOLBAR:
|
||||
urlpatterns.append(path("__debug__/", include("debug_toolbar.urls")))
|
||||
|
||||
@@ -9,3 +9,25 @@ select = ["E", "W", "F", "I", "B", "C4", "UP", "DJ", "SIM"]
|
||||
"jobs/models.py" = ["DJ001"]
|
||||
"jobs/migrations/*.py" = ["E501"]
|
||||
"jobs/tests/*.py" = ["DJ001"]
|
||||
|
||||
[tool.coverage.run]
|
||||
branch = true
|
||||
source = ["config", "jobs"]
|
||||
omit = [
|
||||
"config/asgi.py",
|
||||
"config/wsgi.py",
|
||||
"jobs/management/*",
|
||||
"*/migrations/*",
|
||||
"*/tests/*",
|
||||
"manage.py",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
skip_covered = true
|
||||
fail_under = 60
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if __name__ == .__main__.:",
|
||||
"raise NotImplementedError",
|
||||
]
|
||||
|
||||
@@ -5,6 +5,8 @@ drf-spectacular>=0.28,<0.29
|
||||
python-dotenv>=1.1,<2.0
|
||||
psycopg[binary]>=3.2,<4.0
|
||||
django-unfold>=0.76,<1.0
|
||||
django-debug-toolbar>=5.0,<6.0
|
||||
pytest>=8.0,<9.0
|
||||
pytest-django>=4.9,<5.0
|
||||
pytest-cov>=6.0,<7.0
|
||||
ruff>=0.15,<0.16
|
||||
|
||||
@@ -22,6 +22,7 @@ services:
|
||||
command: sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000"
|
||||
environment:
|
||||
DEBUG: "true"
|
||||
ENABLE_DJANGO_DEBUG_TOOLBAR: "true"
|
||||
DJANGO_ALLOWED_HOSTS: localhost,127.0.0.1,0.0.0.0
|
||||
DJANGO_CORS_ALLOWED_ORIGINS: http://localhost:5173,http://127.0.0.1:5173
|
||||
POSTGRES_DB: ${POSTGRES_DB:-job_queue}
|
||||
|
||||
BIN
slides/images/01-cover.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
slides/images/02-contract.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
slides/images/03-scope.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
slides/images/04-architecture.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
slides/images/05-model.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
slides/images/06-claim.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
slides/images/07-state.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
slides/images/08-ownership.png
Normal file
|
After Width: | Height: | Size: 1003 KiB |
BIN
slides/images/09-recovery.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
slides/images/10-retries.png
Normal file
|
After Width: | Height: | Size: 1010 KiB |
BIN
slides/images/11-ui.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
slides/images/12-tests.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
slides/images/13-tradeoff.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
slides/images/14-takeaway.png
Normal file
|
After Width: | Height: | Size: 1022 KiB |
19
slides/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Minimal PostgreSQL Job Queue - Interview Presentation</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-progress" aria-hidden="true">
|
||||
<span id="progressFill"></span>
|
||||
</div>
|
||||
|
||||
<main class="deck" aria-live="polite"></main>
|
||||
<aside class="notes" id="notes" aria-label="speaker notes"></aside>
|
||||
|
||||
<script src="slides.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
259
slides/slides.js
Normal file
@@ -0,0 +1,259 @@
|
||||
const slides = [
|
||||
{
|
||||
eyebrow: '01 / Opening',
|
||||
title: 'Minimal PostgreSQL Job Queue',
|
||||
subtitle: 'A small queue that proves safe claim, deterministic state, and visible execution.',
|
||||
image: 'images/01-cover.png',
|
||||
notes: [
|
||||
'Open by framing the assignment: build an internal job queue, not a full queue product.',
|
||||
'The main promise is correctness: multiple workers can run, but a single job receives a single execution owner.',
|
||||
'The implementation is intentionally small so every important behavior is explainable during the interview.'
|
||||
]
|
||||
},
|
||||
{
|
||||
eyebrow: '02 / Problem',
|
||||
title: 'What the Assignment Really Tests',
|
||||
subtitle: 'Create jobs, claim them safely, manage states, and trace what happened.',
|
||||
image: 'images/02-contract.png',
|
||||
notes: [
|
||||
'The assignment asks for job creation, worker claim, job status management, and execution tracking.',
|
||||
'The central invariant is: one job must not be claimed and executed by two workers at the same time.',
|
||||
'It also asks that worker failure behavior and the main status transitions are clear and testable.'
|
||||
]
|
||||
},
|
||||
{
|
||||
eyebrow: '03 / Scope',
|
||||
title: 'Small Queue, Not a Queue Platform',
|
||||
subtitle: 'The design keeps the model minimal and moves complexity into deterministic rules.',
|
||||
image: 'images/03-scope.png',
|
||||
notes: [
|
||||
'There is one internal priority queue, not user-defined queues.',
|
||||
'There is no queue table and no worker table. Workers are ephemeral process threads configured through settings and environment variables.',
|
||||
'Authentication and advanced operator permissions are deliberately omitted for interview simplicity, but documented as production follow-ups.'
|
||||
]
|
||||
},
|
||||
{
|
||||
eyebrow: '04 / Architecture',
|
||||
title: '1 Database, 2 Django Roles, 1 UI',
|
||||
subtitle: 'The API exposes queue operations; the worker process claims jobs from PostgreSQL.',
|
||||
image: 'images/04-architecture.png',
|
||||
notes: [
|
||||
'React is only the demo and observability surface. It does not own queue state.',
|
||||
'Django API creates jobs, lists jobs and events, exposes stats, and supports manual retry of failed jobs.',
|
||||
'The worker is a separate Django process with N configured threads; each thread claims work from PostgreSQL.'
|
||||
]
|
||||
},
|
||||
{
|
||||
eyebrow: '05 / Model',
|
||||
title: 'Two Tables Are Enough',
|
||||
subtitle: 'The job row carries state; the event row explains the history.',
|
||||
image: 'images/05-model.png',
|
||||
notes: [
|
||||
'<code>jobs</code> stores type, payload, status, priority, available time, attempts, max attempts, lock owner, lock deadline, result, and timestamps.',
|
||||
'<code>job_events</code> is append-only observability: created, claimed, progress, lease renewed, retry scheduled, timeout requeued, succeeded, failed, and manual retry.',
|
||||
'Database constraints validate row shape: queued jobs have no lock, running jobs must have a lock, and terminal jobs must have a finish timestamp.'
|
||||
]
|
||||
},
|
||||
{
|
||||
eyebrow: '06 / State',
|
||||
title: 'Status Transitions Are Explicit',
|
||||
subtitle: 'The queue allows only meaningful movement between queued, running, succeeded, and failed.',
|
||||
image: 'images/07-state.png',
|
||||
notes: [
|
||||
'The normal path is queued -> running -> succeeded.',
|
||||
'Failure or timeout can move running back to queued when attempts remain.',
|
||||
'When attempts are exhausted, the job becomes failed. Manual retry can move failed back to queued and resets the attempt count.'
|
||||
]
|
||||
},
|
||||
{
|
||||
eyebrow: '07 / Safe Claim',
|
||||
title: 'PostgreSQL Chooses the Winner',
|
||||
subtitle: 'Atomic claim uses row locks and SKIP LOCKED to avoid double execution.',
|
||||
image: 'images/06-claim.png',
|
||||
notes: [
|
||||
'The worker selects the next eligible queued job ordered by priority, available_at, created_at, and id.',
|
||||
'Inside one transaction, it uses <code>SELECT ... FOR UPDATE SKIP LOCKED</code>, then marks the row running, increments attempts, and sets the lease.',
|
||||
'When many threads race, one locks the row; the others skip it instead of waiting and accidentally claiming the same job.'
|
||||
]
|
||||
},
|
||||
{
|
||||
eyebrow: '08 / Ownership',
|
||||
title: 'Current Owner + Attempt Is the Write Token',
|
||||
subtitle: 'A stale worker cannot complete or fail a job after ownership changes.',
|
||||
image: 'images/08-ownership.png',
|
||||
notes: [
|
||||
'Completion, failure, progress, and lease renewal all filter by job id, running status, locked_by, and attempt.',
|
||||
'That means a worker that crashed and later wakes up cannot complete attempt 1 after the job was requeued and claimed as attempt 2.',
|
||||
'This is the practical guardrail that makes lease recovery safe.'
|
||||
]
|
||||
},
|
||||
{
|
||||
eyebrow: '09 / Recovery',
|
||||
title: 'Worker Failure Is a Timeout, Not a Mystery',
|
||||
subtitle: 'Expired leases are cleaned up deterministically.',
|
||||
image: 'images/09-recovery.png',
|
||||
notes: [
|
||||
'Workers periodically run cleanup for running jobs whose locked_until is in the past.',
|
||||
'If attempts remain, the job is requeued and a timeout event is recorded. If attempts are exhausted, it becomes failed.',
|
||||
'This queue provides at-least-once execution, not exactly-once execution. External side-effect handlers should be idempotent.'
|
||||
]
|
||||
},
|
||||
{
|
||||
eyebrow: '10 / Retry',
|
||||
title: 'Retries Are Bounded and Scheduled',
|
||||
subtitle: 'Failures return to the queue with deterministic exponential backoff.',
|
||||
image: 'images/10-retries.png',
|
||||
notes: [
|
||||
'<code>fail_job</code> either schedules the next attempt or marks the job failed when max_attempts is reached.',
|
||||
'Backoff is deterministic and stored through available_at, so workers do not need hidden memory.',
|
||||
'Idempotency keys prevent duplicate producer submissions for the same logical job.'
|
||||
]
|
||||
},
|
||||
{
|
||||
eyebrow: '11 / UI',
|
||||
title: 'Demo-Friendly Observability',
|
||||
subtitle: 'The UI shows state, pressure, attempts, leases, and event history while workers run.',
|
||||
image: 'images/11-ui.png',
|
||||
notes: [
|
||||
'Dashboard polls jobs, stats, events, and health every 2 seconds.',
|
||||
'Jobs page uses limit-offset pagination, status/type filters, and refreshes every 2 seconds.',
|
||||
'Job detail polls the job and its timeline every 1 second; global events use cursor pagination and fetch older pages as the terminal log scrolls.'
|
||||
]
|
||||
},
|
||||
{
|
||||
eyebrow: '12 / Tests',
|
||||
title: 'Tests Target the Risky Parts',
|
||||
subtitle: 'The most important tests are around ownership, concurrency, and recovery.',
|
||||
image: 'images/12-tests.png',
|
||||
notes: [
|
||||
'There are service-level tests for successful claim, deterministic ordering, idempotent creation, retry scheduling, exhausted failure, timeout cleanup, stale worker rejection, and lease renewal ownership.',
|
||||
'The PostgreSQL concurrency test runs multiple claimers and asserts only one claimed event exists.',
|
||||
'API tests cover job pagination, filters, cursor-based global events, and older-page loading.'
|
||||
]
|
||||
},
|
||||
{
|
||||
eyebrow: '13 / Trade-off',
|
||||
title: 'PostgreSQL Now, Broker Later',
|
||||
subtitle: 'PostgreSQL is ideal for this interview; Redis or a broker is the scale path.',
|
||||
image: 'images/13-tradeoff.png',
|
||||
notes: [
|
||||
'The assignment requires PostgreSQL, and PostgreSQL makes the correctness story compact and inspectable.',
|
||||
'For very high throughput, cross-service distribution, or advanced queue features, I would move to a Redis-backed or dedicated broker design.',
|
||||
'The important point is that I did not hide over-engineering in the interview implementation; I documented it as a next architecture.'
|
||||
]
|
||||
},
|
||||
{
|
||||
eyebrow: '14 / Close',
|
||||
title: 'What This Proves',
|
||||
subtitle: 'Minimal implementation with production-aware guarantees.',
|
||||
image: 'images/14-takeaway.png',
|
||||
notes: [
|
||||
'The system is safe because PostgreSQL owns the claim decision.',
|
||||
'It is deterministic because state transitions and row shapes are explicit and validated.',
|
||||
'It is demoable because the UI and events make execution visible without adding unnecessary queue models.'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const deck = document.querySelector('.deck');
|
||||
const notes = document.getElementById('notes');
|
||||
const progressFill = document.getElementById('progressFill');
|
||||
|
||||
let current = Math.max(0, Math.min(slides.length - 1, Number(location.hash.replace('#', '')) - 1 || 0));
|
||||
let notesOpen = false;
|
||||
|
||||
function renderDeck() {
|
||||
deck.innerHTML = slides.map((slide, index) => `
|
||||
<section class="slide" data-index="${index}" aria-hidden="${index === current ? 'false' : 'true'}">
|
||||
<header class="slide-header">
|
||||
<span class="eyebrow">${slide.eyebrow}</span>
|
||||
<h1>${slide.title}</h1>
|
||||
<p class="subtitle">${slide.subtitle}</p>
|
||||
</header>
|
||||
<div class="visual"><img src="${slide.image}" alt="${slide.title} visual" /></div>
|
||||
</section>
|
||||
`).join('');
|
||||
update(false);
|
||||
}
|
||||
|
||||
function update(shouldUpdateHash = true) {
|
||||
deck.querySelectorAll('.slide').forEach((slide, index) => {
|
||||
slide.classList.toggle('active', index === current);
|
||||
slide.setAttribute('aria-hidden', index === current ? 'false' : 'true');
|
||||
});
|
||||
|
||||
progressFill.style.transform = `scaleX(${(current + 1) / slides.length})`;
|
||||
|
||||
if (shouldUpdateHash) {
|
||||
location.hash = String(current + 1);
|
||||
}
|
||||
|
||||
renderNotes();
|
||||
}
|
||||
|
||||
function renderNotes() {
|
||||
const slide = slides[current];
|
||||
notes.innerHTML = `<h2>Speaker notes - ${String(current + 1).padStart(2, '0')}. ${slide.title}</h2><ul>${slide.notes.map((note) => `<li>${note}</li>`).join('')}</ul>`;
|
||||
notes.classList.toggle('open', notesOpen);
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (current < slides.length - 1) {
|
||||
current += 1;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
function prev() {
|
||||
if (current > 0) {
|
||||
current -= 1;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleNotes() {
|
||||
notesOpen = !notesOpen;
|
||||
renderNotes();
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (['ArrowRight', 'PageDown', ' '].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
next();
|
||||
}
|
||||
|
||||
if (['ArrowLeft', 'PageUp'].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
prev();
|
||||
}
|
||||
|
||||
if (event.key === 'Home') {
|
||||
current = 0;
|
||||
update();
|
||||
}
|
||||
|
||||
if (event.key === 'End') {
|
||||
current = slides.length - 1;
|
||||
update();
|
||||
}
|
||||
|
||||
if (event.key.toLowerCase() === 'n') {
|
||||
toggleNotes();
|
||||
}
|
||||
|
||||
if (event.key === 'Escape' && notesOpen) {
|
||||
notesOpen = false;
|
||||
renderNotes();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
const nextIndex = Number(location.hash.replace('#', '')) - 1;
|
||||
|
||||
if (Number.isFinite(nextIndex) && nextIndex >= 0 && nextIndex < slides.length && nextIndex !== current) {
|
||||
current = nextIndex;
|
||||
update(false);
|
||||
}
|
||||
});
|
||||
|
||||
renderDeck();
|
||||
290
slides/styles.css
Normal file
@@ -0,0 +1,290 @@
|
||||
:root {
|
||||
--bg: #f8fbff;
|
||||
--ink: #0f172a;
|
||||
--muted: #64748b;
|
||||
--accent: #2563eb;
|
||||
--accent-strong: #7c3aed;
|
||||
--line: #e2e8f0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(180deg, #ffffff 0%, var(--bg) 42%, #eef6ff 100%);
|
||||
color: var(--ink);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.top-progress {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
width: 100vw;
|
||||
height: 5px;
|
||||
overflow: hidden;
|
||||
background: rgba(226, 232, 240, .82);
|
||||
}
|
||||
|
||||
.top-progress span {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-strong));
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
transition: transform 420ms cubic-bezier(.22, 1, .36, 1);
|
||||
}
|
||||
|
||||
.deck {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.slide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
grid-template-rows: max-content minmax(0, 1fr);
|
||||
gap: clamp(14px, 2vh, 24px);
|
||||
height: 100dvh;
|
||||
padding: clamp(34px, 5vh, 58px) clamp(20px, 4.8vw, 74px) clamp(20px, 4vh, 42px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(10px);
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 220ms ease;
|
||||
}
|
||||
|
||||
.slide.active {
|
||||
z-index: 2;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.slide-header {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 30px;
|
||||
margin-bottom: 16px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, .86);
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
box-shadow: 0 10px 26px rgba(15, 23, 42, .05);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(42px, 5vw, 72px);
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.055em;
|
||||
line-height: .96;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 18px auto 0;
|
||||
color: var(--muted);
|
||||
font-size: clamp(18px, 2vw, 25px);
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.visual {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.visual img {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.notes {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 34px;
|
||||
z-index: 20;
|
||||
width: min(1060px, calc(100vw - 60px));
|
||||
max-height: 38vh;
|
||||
overflow: auto;
|
||||
padding: 22px 26px;
|
||||
border: 1px solid rgba(255, 255, 255, .13);
|
||||
border-radius: 24px;
|
||||
background: rgba(15, 23, 42, .94);
|
||||
box-shadow: 0 24px 60px rgba(2, 6, 23, .35);
|
||||
color: #e2e8f0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%) translateY(24px);
|
||||
transition: opacity 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.notes.open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.notes h2 {
|
||||
margin: 0 0 10px;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
letter-spacing: .01em;
|
||||
}
|
||||
|
||||
.notes ul {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.notes li {
|
||||
color: #cbd5e1;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.notes code {
|
||||
padding: 1px 5px;
|
||||
border: 1px solid rgba(147, 197, 253, .15);
|
||||
border-radius: 6px;
|
||||
background: rgba(37, 99, 235, .16);
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.slide {
|
||||
padding: 34px 24px 24px;
|
||||
}
|
||||
|
||||
.visual img {
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1536px), (max-height: 850px) {
|
||||
.eyebrow {
|
||||
height: 28px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(32px, 4.1vw, 56px);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 12px;
|
||||
font-size: clamp(15px, 1.55vw, 20px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 720px) {
|
||||
.slide {
|
||||
gap: 12px;
|
||||
padding-top: 28px;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
height: 26px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(30px, 6vh, 48px);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 10px;
|
||||
font-size: clamp(14px, 2.4vh, 18px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 560px) {
|
||||
.slide {
|
||||
padding-top: 18px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(28px, 7vh, 42px);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: clamp(14px, 3vh, 17px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.top-progress span,
|
||||
.slide,
|
||||
.notes {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
overflow: visible;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.top-progress,
|
||||
.notes {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.deck {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.slide {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
page-break-after: always;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||