Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,6 @@ gloo-timers = { version = "0.3", features = ["futures"] }
tracing = "0.1"
tracing-wasm = "0.2"
console_error_panic_hook = "0.1"
web-sys = { version = "0.3", features = ["Window", "Location"] }
web-sys = { version = "0.3", features = ["Window", "Location", "MediaQueryList", "MediaQueryListEvent"] }
js-sys = "0.3"
futures = "0.3"
12 changes: 12 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,18 @@ by default.

## Layout

### Breakpoints

- **Mobile:** < 480px. Single column, collapsed nav, card view, secondary
row details hidden.
- **Tablet:** 480–767px. Compact spacing, dashboard forced to card view.
- **Desktop-small:** 768–1023px. Full nav and table restored.
- **Desktop:** ≥ 1024px. Full experience (content max 1280px).

Media queries use these exact pixel values — CSS custom properties cannot
be used inside `@media`, so the values live in `style.css` under
"Responsive breakpoints" and this section documents them.

### Grid

- **Max width:** 1280px, centered.
Expand Down
20 changes: 13 additions & 7 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,15 +134,21 @@ dark-first/sky-blue identity was reverted in `294d214`; needs a redo.
Most developers check dashboards on desktop, but a quick phone check should
work too.

- [ ] **Breakpoint system.** Mobile, tablet, desktop-small, desktop.
- [ ] **Table → card fallback.** The sortable table is unusable on small
screens. Force card view below tablet width.
- [ ] **Responsive navigation.** Top nav on desktop; collapsed menu on
mobile.
- [ ] **Touch targets.** All interactive elements meet WCAG AA sizing.
- [x] **Breakpoint system.** Mobile (<480px), tablet (480–767px),
desktop-small (768–1023px), desktop (≥1024px). Documented in DESIGN.md
"Layout > Breakpoints", with the pixel values written out at each
`@media` query in `style.css`.
- [x] **Table → card fallback.** The dashboard watches
`matchMedia("(max-width: 767px)")`, forces the card view below the
tablet breakpoint and hides the Table/Cards toggle.
- [x] **Responsive navigation.** Hamburger button below 768px; the nav
links become a dropdown panel under the sticky header and close on
selection. Theme toggle + rate badge stay in the top row.
- [x] **Touch targets.** `@media (pointer: coarse)` gives interactive
elements a 44px minimum height; desktop density is untouched.

**Acceptance:** usable from phone to ultrawide; table auto-converts to
cards on mobile.
cards on mobile. ✅ Phase complete.

### 5. Keyboard navigation

Expand Down
23 changes: 22 additions & 1 deletion crates/app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ pub fn App() -> impl IntoView {

let settings = expect_context::<SettingsState>();

// Mobile nav: the links collapse behind this menu button below the tablet
// breakpoint (see DESIGN.md "Breakpoints").
let (menu_open, set_menu_open) = signal(false);

// Reflect the theme onto the document root so `:root[data-theme]` CSS applies.
let theme_attr = move || settings.theme.get().as_attr();
leptos::prelude::Effect::new(move |_| {
Expand Down Expand Up @@ -67,18 +71,23 @@ pub fn App() -> impl IntoView {
</svg>
"Lepo"
</a>
<nav class="primary-nav-links">
<nav
class="primary-nav-links"
class:nav-open=move || menu_open.get()
>
<a
href="/"
class="nav-link"
class:active=move || location.pathname.get() == "/"
on:click=move |_| { set_menu_open.set(false); }
>
"Dashboard"
</a>
<a
href="/settings"
class="nav-link"
class:active=move || location.pathname.get() == "/settings"
on:click=move |_| { set_menu_open.set(false); }
>
"Settings"
</a>
Expand Down Expand Up @@ -108,6 +117,18 @@ pub fn App() -> impl IntoView {
</button>
<RateLimitBadge/>
</div>
<button
class="nav-menu-btn"
aria-label="Toggle navigation"
aria-expanded=move || menu_open.get()
on:click=move |_| {
set_menu_open.update(|open| *open = !*open);
}
>
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M4 7h16M4 12h16M4 17h16"/>
</svg>
</button>
</div>
</header>
}
Expand Down
19 changes: 19 additions & 0 deletions crates/app/src/pages/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,25 @@ pub fn DashboardPage() -> impl IntoView {

// View / filter / sort state.
let (view_mode, set_view_mode) = signal(ViewMode::Table);

// Below the tablet breakpoint (767px, DESIGN.md "Breakpoints") the table
// is unusable, so the dashboard forces the card view and the toggle is
// hidden via CSS. Resizing back up restores the user's choice.
let (is_compact, set_is_compact) = signal(false);
leptos::prelude::Effect::new(move |_| {
if is_compact.get() {
set_view_mode.set(ViewMode::Cards);
}
});
if let Ok(Some(mql)) = window().match_media("(max-width: 767px)") {
set_is_compact.set(mql.matches());
let listener = mql.clone();
let cb = Closure::<dyn FnMut()>::new(move || set_is_compact.set(listener.matches()));
let _ = mql.add_event_listener_with_callback("change", cb.as_ref().unchecked_ref());
// The closure is not Send+Sync, so it can't be stored for on_cleanup;
// forget() keeps it alive for the page lifetime (one per mount).
cb.forget();
}
let (query, set_query) = signal(String::new());
let (sort_key, set_sort_key) = signal(SortKey::Name);
let (sort_asc, set_sort_asc) = signal(true);
Expand Down
130 changes: 119 additions & 11 deletions crates/app/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@
--content-max: 1280px;
}

/* ---------- Responsive breakpoints ----------
Documented in DESIGN.md "Layout > Breakpoints":
mobile < 480px
tablet 480-767px
desktop-small 768-1023px
desktop >= 1024px
CSS custom properties can't be used inside @media, so these exact
pixel values are repeated at each query below. */

/* Dark theme overrides */
:root[data-theme="dark"] {
--colors-canvas: #1b1b18;
Expand Down Expand Up @@ -165,6 +174,7 @@ a:hover {
border-bottom: 1px solid var(--colors-hairline);
}
.primary-nav-inner {
position: relative;
display: flex;
align-items: center;
gap: var(--spacing-xl);
Expand Down Expand Up @@ -234,6 +244,22 @@ a:hover {
gap: var(--spacing-sm);
padding-left: var(--spacing-md);
}
.nav-menu-btn {
display: none;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: var(--rounded-sm);
background: transparent;
color: var(--colors-ink);
cursor: pointer;
transition: background 120ms ease;
}
.nav-menu-btn:hover {
background: var(--colors-surface-card);
}

.app-main {
flex: 1 1 auto;
Expand Down Expand Up @@ -824,9 +850,7 @@ a:hover {
background: var(--colors-canvas);
color: var(--colors-ink);
box-shadow: var(--shadow-sm);
}

.summary-strip {
}.summary-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--spacing-md);
Expand Down Expand Up @@ -1020,19 +1044,103 @@ a:hover {
margin-bottom: var(--spacing-md);
}

/* ---------- Responsive ---------- */
@media (max-width: 768px) {
/* ---------- Responsive: tablet (max-width: 767px) ---------- */
@media (max-width: 767px) {
.app-main {
padding: var(--spacing-xl) var(--spacing-md) var(--spacing-section);
padding: var(--spacing-lg) var(--spacing-md) var(--spacing-xxl);
}
.primary-nav {
padding: 0 var(--spacing-md);
gap: var(--spacing-md);
.heading-lg {
font-size: 28px;
}
.repo-grid {
grid-template-columns: 1fr;
}
.heading-lg {
font-size: 32px;
.view-toggle {
display: none;
}
.nav-menu-btn {
display: inline-flex;
}
.primary-nav-inner {
padding: 0 var(--spacing-md);
gap: var(--spacing-sm);
}
.primary-nav-rate {
padding-left: 0;
gap: var(--spacing-xs);
}
.primary-nav-links {
display: none;
position: absolute;
top: var(--nav-height);
left: 0;
right: 0;
flex-direction: column;
align-items: stretch;
background: var(--colors-canvas);
border-bottom: 1px solid var(--colors-hairline);
padding: var(--spacing-sm) var(--spacing-md) var(--spacing-md);
gap: var(--spacing-xxs);
}
.primary-nav-links.nav-open {
display: flex;
}
.nav-link.active::after {
display: none;
}
.row {
flex-wrap: wrap;
gap: var(--spacing-sm);
}
.row-updated {
display: none;
}
.load-more {
width: 100%;
}
}

/* ---------- Responsive: mobile (max-width: 479px) ---------- */
@media (max-width: 479px) {
.page-header {
flex-direction: column;
align-items: flex-start;
}
.dash-toolbar {
align-items: stretch;
}
.summary-strip {
grid-template-columns: repeat(2, 1fr);
}
.row-comments {
display: none;
}
.page-login {
margin-top: var(--spacing-xxl);
}
}

/* ---------- Touch targets: coarse pointers (see DESIGN.md) ----------
WCAG AA minimum target size is 24px; 44px is the comfortable standard
for touch screens, applied only where the primary pointer is coarse so
desktop density is untouched. */
@media (pointer: coarse) {
.nav-link,
.filter-chip,
.view-toggle button,
.theme-toggle,
.nav-menu-btn,
.load-more,
.button-primary,
.button-secondary,
.button-tertiary,
.text-input {
min-height: 44px;
}
.tabs .filter-chip {
padding-inline: 16px;
}
.row {
padding-block: var(--spacing-lg);
}
}