diff --git a/.bolt/skills/slides/SKILL.md b/.bolt/skills/slides/SKILL.md
index 99b6e5b..d98189d 100644
--- a/.bolt/skills/slides/SKILL.md
+++ b/.bolt/skills/slides/SKILL.md
@@ -216,6 +216,10 @@ to the screen, so nothing scales-and-clips:
with **`.appmock`** (sidebar + content; sidebar column collapses on phones) and
put **`.hide-narrow`** on chrome that should vanish on small screens.
- **One idea per slide**, sized to fill ~one screen with deliberate negative space.
+- **Phones (≤640px)**: a slide taller than the screen scrolls vertically instead of
+ clipping, and swiping left/right pages the deck (`StatGrid` also compacts to
+ 2-up cards there — built in). Still aim to fit one screen; scroll is the escape
+ hatch, not the layout plan.
- **Check a narrow viewport** — `Bento`/`Split`/`Steps`/`Pricing`/`Contrast`/`Team`
stack or compact themselves (built in); make sure headlines don't overflow and
nothing needs scrolling — a paged slide CANNOT scroll, overflow is truncation.
diff --git a/index.html b/index.html
index fb4074a..0ad76f1 100644
--- a/index.html
+++ b/index.html
@@ -2,7 +2,7 @@
-
+
Replace — your deck title
@@ -263,6 +264,48 @@ export default function Deck({ children }: { children: ReactNode }) {
return () => window.removeEventListener('keydown', onKey);
}, [next, prev, go, total, toggleRail, toggleGrid, toggleFs, openPresenter]);
+ // touch: a quick horizontal swipe pages; vertical drags stay free so a
+ // slide taller than a phone screen can scroll (see base.css ≤640px)
+ useEffect(() => {
+ let x0 = 0,
+ y0 = 0,
+ t0 = 0,
+ live = false;
+ const onStart = (e: TouchEvent) => {
+ const t = e.target as HTMLElement | null;
+ if (
+ e.touches.length !== 1 ||
+ t?.closest(
+ '.noir-dock,.noir-rail,.noir-grid,.noir-presenter,.ann-bar,.ann-canvas,button,a,textarea,input'
+ )
+ ) {
+ live = false;
+ return;
+ }
+ live = true;
+ x0 = e.touches[0].clientX;
+ y0 = e.touches[0].clientY;
+ t0 = performance.now();
+ };
+ const onEnd = (e: TouchEvent) => {
+ if (!live) return;
+ live = false;
+ const dx = e.changedTouches[0].clientX - x0;
+ const dy = e.changedTouches[0].clientY - y0;
+ // slow drags and diagonal/vertical moves are scroll intent, not paging
+ if (performance.now() - t0 > 600) return;
+ if (Math.abs(dx) < 48 || Math.abs(dx) < 1.5 * Math.abs(dy)) return;
+ if (dx < 0) next();
+ else prev();
+ };
+ window.addEventListener('touchstart', onStart, { passive: true });
+ window.addEventListener('touchend', onEnd, { passive: true });
+ return () => {
+ window.removeEventListener('touchstart', onStart);
+ window.removeEventListener('touchend', onEnd);
+ };
+ }, [next, prev]);
+
// safety net: if the authored deck kept the placeholder tab title, derive
// one from the current slide's heading so shared links look right.
useEffect(() => {
diff --git a/src/styles/base.css b/src/styles/base.css
index 7d4c1f1..59976ba 100644
--- a/src/styles/base.css
+++ b/src/styles/base.css
@@ -1827,6 +1827,61 @@ strong {
grid-template-columns: 180px 1fr;
}
@media (max-width: 640px) {
+ /* a slide taller than the screen scrolls instead of clipping both ends
+ (flex centering can't overflow-scroll upward) — `safe center` keeps short
+ slides centered and degrades to top-aligned where unsupported; the extra
+ bottom padding only bites on slides that scroll, clearing the dock */
+ .slide {
+ overflow-y: auto;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+ justify-content: flex-start;
+ justify-content: safe center;
+ padding-bottom: calc(var(--gutter-y) + 92px);
+ }
+ /* the -15% atmosphere bleed (and drift's scale) would give every slide
+ ~140px of phantom scroll once slides are scroll containers — pin the
+ gradient static; the 26s drift isn't perceptible at phone size */
+ .slide::before {
+ inset: 0;
+ animation: none;
+ transform: none;
+ }
+ /* stacked split: media keeps its natural height instead of being crushed
+ into the leftover row, and the slide scrolls when the stack runs long */
+ .slide.full {
+ padding-bottom: 0;
+ }
+ .split {
+ height: auto;
+ min-height: 100%;
+ grid-template-rows: auto minmax(min-content, 1fr);
+ padding-bottom: 92px;
+ }
+ /* stat grid: 2-up compact cards so six stats fit one phone screen —
+ full-width stacking runs ~1.5 screens tall */
+ .stat-grid {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 10px;
+ }
+ .stat-card {
+ padding: 14px 12px;
+ gap: 4px;
+ }
+ .stat-card .tick {
+ margin-bottom: 2px;
+ }
+ .stat-value {
+ font-size: clamp(24px, 7vw, 34px);
+ }
+ .stat-label {
+ font-size: 13.5px;
+ margin-top: 4px;
+ }
+ .stat-caption {
+ font-size: 12px;
+ line-height: 1.35;
+ }
.appmock {
grid-template-columns: 1fr;
}
@@ -1841,17 +1896,26 @@ strong {
.ch-donut {
--ch-size: 120px !important;
}
- /* data tables keep their column integrity and pan horizontally instead */
+ /* data tables FIT the phone width — sideways panning buries the far
+ columns. Type/pads shrink, headers and label cells wrap; number strings
+ carry no break points so they stay whole. overflow-x remains only as the
+ escape hatch for genuinely too-wide tables. */
.dtable {
overflow-x: auto;
}
.dtable table {
- min-width: 540px;
+ min-width: 0;
+ font-size: 11px;
+ }
+ .dtable th {
+ font-size: 9px;
+ letter-spacing: 0.05em;
+ padding: 7px 6px;
+ white-space: normal;
}
- .dtable th,
.dtable td {
- white-space: nowrap;
- padding: 9px 12px;
+ padding: 8px 6px;
+ white-space: normal;
}
}
@@ -2716,7 +2780,7 @@ strong {
@media (max-width: 640px) {
.noir-dock {
opacity: 1;
- bottom: 14px;
+ bottom: calc(14px + env(safe-area-inset-bottom, 0px));
display: flex;
flex-direction: column;
align-items: center;
@@ -2768,7 +2832,7 @@ strong {
/* annotation toolbar: one swipeable row on phones (wrapping made the
999px-radius pill collapse into a pinched oval); clears the taller dock */
.ann-bar {
- bottom: 104px;
+ bottom: calc(104px + env(safe-area-inset-bottom, 0px));
flex-wrap: nowrap;
justify-content: flex-start;
overflow-x: auto;