This commit is contained in:
parent
c0742994cd
commit
1a5fc9428a
@ -37,6 +37,6 @@ One global rule at the end of `base.css` collapses every animation/transition to
|
|||||||
|
|
||||||
- Flexbox + CSS Grid with `gap` only; floats for layout are forbidden.
|
- Flexbox + CSS Grid with `gap` only; floats for layout are forbidden.
|
||||||
- Approved page layouts and the shared shell are documented in `/docs/styles-layout.html`; a page supplies exactly one layout container inside `.page`.
|
- Approved page layouts and the shared shell are documented in `/docs/styles-layout.html`; a page supplies exactly one layout container inside `.page`.
|
||||||
- Every fluid grid/flex column sets `min-width: 0`.
|
- Every fluid grid/flex column sets `min-width: 0`, and every fluid grid track that holds content is written `minmax(0, 1fr)`, never a bare `1fr`. A `1fr` track's automatic minimum is the item's min-content size, so one unwrappable line inside a rendered code block (`.rendered-content pre`, `white-space: pre`) widens the track and blows the whole page open sideways. This is why a detail page (a `max-width` block, definite width, the `pre` scrolls inside it) survives content that destroys a listing grid. The rule covers a column that is fixed-width at desktop but becomes the single fluid column at a breakpoint (`.profile-sidebar`), and it is regression-tested by `assert_no_horizontal_overflow` in `tests/conftest.py`.
|
||||||
- `!important` is allowed only for: the `.hidden`/`[hidden]` display utilities, the global reduced-motion rule, and the devii-avatar third-party-beating override. Anything else is a specificity problem to be fixed structurally.
|
- `!important` is allowed only for: the `.hidden`/`[hidden]` display utilities, the global reduced-motion rule, and the devii-avatar third-party-beating override. Anything else is a specificity problem to be fixed structurally.
|
||||||
- Page-specific CSS lives in its own `static/css/*.css` loaded via `{% block extra_head %}`, never an inline `<style>` block; shared component styles live once (`components.css`, `feed.css` vote buttons) and are never redefined per page.
|
- Page-specific CSS lives in its own `static/css/*.css` loaded via `{% block extra_head %}`, never an inline `<style>` block; shared component styles live once (`components.css`, `feed.css` vote buttons) and are never redefined per page.
|
||||||
|
|||||||
@ -43,7 +43,7 @@
|
|||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.awards-grid {
|
.awards-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,9 @@
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feed-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.feed-nav {
|
.feed-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -17,6 +17,10 @@
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gists-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.gists-header {
|
.gists-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -36,7 +40,7 @@
|
|||||||
|
|
||||||
.gists-grid {
|
.gists-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -141,6 +141,10 @@
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-feed {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-feed-header {
|
.dashboard-feed-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -167,7 +171,7 @@
|
|||||||
|
|
||||||
.dashboard-posts-grid {
|
.dashboard-posts-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 0.875rem;
|
gap: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -373,7 +377,7 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
.dashboard-posts-grid {
|
.dashboard-posts-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -713,7 +717,7 @@
|
|||||||
|
|
||||||
.landing-posts-grid {
|
.landing-posts-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -850,7 +854,7 @@
|
|||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
.landing-posts-grid {
|
.landing-posts-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
.news-grid {
|
.news-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -283,7 +283,7 @@
|
|||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.news-grid {
|
.news-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-header h1 {
|
.news-header h1 {
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
.profile-sidebar {
|
.profile-sidebar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: calc(var(--nav-height) + 1rem);
|
top: calc(var(--nav-height) + 1rem);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card {
|
.profile-card {
|
||||||
|
|||||||
@ -7,6 +7,10 @@
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.projects-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.projects-tabs {
|
.projects-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
@ -177,13 +181,13 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.projects-grid {
|
.projects-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.projects-grid {
|
.projects-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.projects-header {
|
.projects-header {
|
||||||
|
|||||||
@ -85,7 +85,7 @@ Content sits on cards built from tokens: `--bg-card`, a `--border` hairline, `--
|
|||||||
- [ ] `{% extends "base.html" %}`; CSS via `extra_head`, JS via `extra_js`.
|
- [ ] `{% extends "base.html" %}`; CSS via `extra_head`, JS via `extra_js`.
|
||||||
- [ ] Two or more breadcrumbs passed in the SEO context (content clears the nav).
|
- [ ] Two or more breadcrumbs passed in the SEO context (content clears the nav).
|
||||||
- [ ] Exactly one layout container inside `.page`; no second `max-width` wrapper.
|
- [ ] Exactly one layout container inside `.page`; no second `max-width` wrapper.
|
||||||
- [ ] Multi-column = grid with `<aside>` rails, fluid column has `min-width: 0`, collapses at `1024px`.
|
- [ ] Multi-column = grid with `<aside>` rails, fluid column has `min-width: 0`, card-grid tracks are `minmax(0, 1fr)`, collapses at `1024px`.
|
||||||
- [ ] One `<h1>` (use `.sr-only` if visually redundant).
|
- [ ] One `<h1>` (use `.sr-only` if visually redundant).
|
||||||
- [ ] No custom header or footer; both come from `base.html`.
|
- [ ] No custom header or footer; both come from `base.html`.
|
||||||
- [ ] Only tokens for colour, spacing, radius, shadow, and z-index; no `var()` fallback values.
|
- [ ] Only tokens for colour, spacing, radius, shadow, and z-index; no `var()` fallback values.
|
||||||
|
|||||||
@ -111,8 +111,8 @@ A new stacked element picks the token matching its role; if none fits, add a tok
|
|||||||
|
|
||||||
## Acceptable and not
|
## Acceptable and not
|
||||||
|
|
||||||
- **Acceptable:** one layout container inside `.page`; CSS Grid for columns; `<aside>` for side columns; `min-width: 0` on the fluid column; sticky rails; cards built from tokens.
|
- **Acceptable:** one layout container inside `.page`; CSS Grid for columns; `<aside>` for side columns; `min-width: 0` on the fluid column; `minmax(0, 1fr)` on card-grid tracks; sticky rails; cards built from tokens.
|
||||||
- **Not acceptable:** a second `max-width`/centred wrapper inside `.page`; absolute positioning to fake columns; fixed pixel heights on content; a side column that does not collapse on small screens; a main column without `min-width: 0` (it will overflow on long code lines).
|
- **Not acceptable:** a second `max-width`/centred wrapper inside `.page`; absolute positioning to fake columns; fixed pixel heights on content; a side column that does not collapse on small screens; a main column without `min-width: 0`, or a card grid on a bare `1fr` track (both overflow on long code lines - the track's automatic minimum is the item's min-content width, and a code block never wraps).
|
||||||
{% endraw %}
|
{% endraw %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -290,6 +290,35 @@ def assert_share_copies(page, expected_fragment):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_no_horizontal_overflow(page, layout):
|
||||||
|
report = page.evaluate(
|
||||||
|
"""(selector) => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const container = document.querySelector(selector);
|
||||||
|
const widest = [];
|
||||||
|
document.querySelectorAll("body *").forEach((el) => {
|
||||||
|
if (el.getBoundingClientRect().right <= root.clientWidth + 1) return;
|
||||||
|
widest.push(
|
||||||
|
el.tagName.toLowerCase() + "." + (el.className || "").toString().trim()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
page: root.scrollWidth - root.clientWidth,
|
||||||
|
layout: container ? container.scrollWidth - container.clientWidth : null,
|
||||||
|
widest: widest.slice(0, 5),
|
||||||
|
};
|
||||||
|
}""",
|
||||||
|
layout,
|
||||||
|
)
|
||||||
|
assert report["layout"] is not None, f"{layout} not found"
|
||||||
|
assert report["page"] <= 1, (
|
||||||
|
f"page scrolls horizontally by {report['page']}px: {report['widest']}"
|
||||||
|
)
|
||||||
|
assert report["layout"] <= 1, (
|
||||||
|
f"{layout} overflows its own width by {report['layout']}px: {report['widest']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def seeded_db(app_server):
|
def seeded_db(app_server):
|
||||||
"""Create test users once at session level using requests directly."""
|
"""Create test users once at session level using requests directly."""
|
||||||
|
|||||||
@ -37,7 +37,11 @@ def _goto_feed_until(page, predicate, timeout=5.0):
|
|||||||
import re
|
import re
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from tests.conftest import BASE_URL, assert_share_copies
|
from tests.conftest import (
|
||||||
|
BASE_URL,
|
||||||
|
assert_no_horizontal_overflow,
|
||||||
|
assert_share_copies,
|
||||||
|
)
|
||||||
from devplacepy.database import get_table
|
from devplacepy.database import get_table
|
||||||
from devplacepy.utils import make_combined_slug
|
from devplacepy.utils import make_combined_slug
|
||||||
def _seed_posts(count):
|
def _seed_posts(count):
|
||||||
@ -965,3 +969,58 @@ def test_feed_scroll_not_restored_on_fresh_visit(alice):
|
|||||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||||
page.wait_for_timeout(600)
|
page.wait_for_timeout(600)
|
||||||
assert page.evaluate("window.scrollY") < 60
|
assert page.evaluate("window.scrollY") < 60
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_post_with_long_code_line(topic="devlog"):
|
||||||
|
owner = str(uuid4())
|
||||||
|
get_table("users").insert(
|
||||||
|
{
|
||||||
|
"uid": owner,
|
||||||
|
"username": f"code_{owner[:8]}",
|
||||||
|
"email": f"{owner[:8]}@code.seed",
|
||||||
|
"password_hash": "x",
|
||||||
|
"role": "Member",
|
||||||
|
"is_active": True,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
uid = str(uuid4())
|
||||||
|
marker = f"longcode-{uid[:8]}"
|
||||||
|
line = "unbreakable_identifier_" * 10
|
||||||
|
get_table("posts").insert(
|
||||||
|
{
|
||||||
|
"deleted_at": None,
|
||||||
|
"deleted_by": None,
|
||||||
|
"uid": uid,
|
||||||
|
"user_uid": owner,
|
||||||
|
"slug": make_combined_slug(marker, uid),
|
||||||
|
"title": marker,
|
||||||
|
"content": f"```python\n{line}\n```",
|
||||||
|
"topic": topic,
|
||||||
|
"project_uid": None,
|
||||||
|
"image": None,
|
||||||
|
"stars": 0,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return marker
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_long_code_line_does_not_widen_layout(alice):
|
||||||
|
page, user = alice
|
||||||
|
marker = _seed_post_with_long_code_line()
|
||||||
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||||
|
card = page.locator(".post-card").filter(has_text=marker).first
|
||||||
|
card.wait_for(state="visible")
|
||||||
|
assert card.locator("pre").count() == 1
|
||||||
|
assert_no_horizontal_overflow(page, ".feed-layout")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_long_code_line_does_not_widen_layout(alice):
|
||||||
|
page, user = alice
|
||||||
|
marker = _seed_post_with_long_code_line()
|
||||||
|
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
|
||||||
|
card = page.locator(".dashboard-post-card").filter(has_text=marker).first
|
||||||
|
card.wait_for(state="visible")
|
||||||
|
assert card.locator("pre").count() == 1
|
||||||
|
assert_no_horizontal_overflow(page, ".dashboard-main")
|
||||||
|
|||||||
@ -6,7 +6,11 @@ import requests
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from playwright.sync_api import expect
|
from playwright.sync_api import expect
|
||||||
from tests.conftest import BASE_URL, assert_share_copies
|
from tests.conftest import (
|
||||||
|
BASE_URL,
|
||||||
|
assert_no_horizontal_overflow,
|
||||||
|
assert_share_copies,
|
||||||
|
)
|
||||||
from devplacepy.database import get_table
|
from devplacepy.database import get_table
|
||||||
from devplacepy.utils import make_combined_slug
|
from devplacepy.utils import make_combined_slug
|
||||||
DPBOT_SIZE = 112147
|
DPBOT_SIZE = 112147
|
||||||
@ -473,3 +477,46 @@ def test_gists_list_preserves_comment_hierarchy(page, app_server):
|
|||||||
expect(card.locator(".post-card-comments")).not_to_contain_text(f"{marker}-excluded")
|
expect(card.locator(".post-card-comments")).not_to_contain_text(f"{marker}-excluded")
|
||||||
nested = card.locator(".post-card-comments .comment-replies .comment-text")
|
nested = card.locator(".post-card-comments .comment-replies .comment-text")
|
||||||
expect(nested).to_contain_text(f"{marker}-reply")
|
expect(nested).to_contain_text(f"{marker}-reply")
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_gist_with_long_code_line():
|
||||||
|
owner = str(uuid4())
|
||||||
|
get_table("users").insert(
|
||||||
|
{
|
||||||
|
"uid": owner,
|
||||||
|
"username": f"gcode_{owner[:8]}",
|
||||||
|
"email": f"{owner[:8]}@gcode.seed",
|
||||||
|
"password_hash": "x",
|
||||||
|
"role": "Member",
|
||||||
|
"is_active": True,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
uid = str(uuid4())
|
||||||
|
marker = f"gistcode-{uid[:8]}"
|
||||||
|
line = "unbreakable_identifier_" * 8
|
||||||
|
get_table("gists").insert(
|
||||||
|
{
|
||||||
|
"deleted_at": None,
|
||||||
|
"deleted_by": None,
|
||||||
|
"uid": uid,
|
||||||
|
"user_uid": owner,
|
||||||
|
"slug": make_combined_slug(marker, uid),
|
||||||
|
"title": marker,
|
||||||
|
"description": f"```python\n{line}\n```",
|
||||||
|
"source_code": "print('x')",
|
||||||
|
"language": "python",
|
||||||
|
"stars": 0,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return marker
|
||||||
|
|
||||||
|
|
||||||
|
def test_gists_long_code_line_does_not_widen_layout(page, app_server):
|
||||||
|
marker = _seed_gist_with_long_code_line()
|
||||||
|
page.goto(f"{BASE_URL}/gists", wait_until="domcontentloaded")
|
||||||
|
card = page.locator(".gist-card").filter(has_text=marker).first
|
||||||
|
card.wait_for(state="visible")
|
||||||
|
assert card.locator("pre").count() == 1
|
||||||
|
assert_no_horizontal_overflow(page, ".gists-layout")
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from playwright.sync_api import expect
|
from playwright.sync_api import expect
|
||||||
|
|
||||||
from tests.conftest import BASE_URL
|
from tests.conftest import BASE_URL, assert_no_horizontal_overflow
|
||||||
|
|
||||||
CONSENT = "ai_third_party"
|
CONSENT = "ai_third_party"
|
||||||
|
|
||||||
@ -65,3 +65,23 @@ def test_a_stranger_never_reaches_the_privacy_panel(bob):
|
|||||||
)
|
)
|
||||||
expect(page.locator(".privacy-panel")).to_have_count(0)
|
expect(page.locator(".privacy-panel")).to_have_count(0)
|
||||||
expect(page.locator("form[action='/profile/alice_test/consent']")).to_have_count(0)
|
expect(page.locator("form[action='/profile/alice_test/consent']")).to_have_count(0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_mobile_long_code_bio_does_not_widen_layout(mobile_page):
|
||||||
|
page, user = mobile_page
|
||||||
|
bio = "```python\n" + "unbreakable_identifier_" * 8 + "\n```"
|
||||||
|
page.request.post(
|
||||||
|
f"{BASE_URL}/profile/update",
|
||||||
|
form={"bio": bio, "location": "", "git_link": "", "website": ""},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
page.goto(
|
||||||
|
f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded"
|
||||||
|
)
|
||||||
|
page.locator(".profile-sidebar pre").first.wait_for(state="visible")
|
||||||
|
assert_no_horizontal_overflow(page, ".profile-layout")
|
||||||
|
finally:
|
||||||
|
page.request.post(
|
||||||
|
f"{BASE_URL}/profile/update",
|
||||||
|
form={"bio": "", "location": "", "git_link": "", "website": ""},
|
||||||
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user