Skip to content

fix(form): keep a field's own margin-bottom regardless of stylesheet load order - #19412

Merged
tobiu merged 2 commits into
neomjs:devfrom
Saadanjum0:fix/form-container-margin-specificity
Oct 6, 2026
Merged

tobiu merged 2 commits into
neomjs:devfrom
Saadanjum0:fix/form-container-margin-specificity

Conversation

@Saadanjum0

Copy link
Copy Markdown
Contributor

Please make sure to read the Contributing Guidelines:

https://github.com/neomjs/neo/blob/dev/CONTRIBUTING.md

Does this PR resolve an issue? (Required)

Resolves #19230

(@Saadanjum01, who claimed the issue, is my other account. The fork lives under this one, so the PR comes from here.)

What kind of change does this PR introduce? (check at least one)

  • Bugfix

Does this PR introduce a breaking change? (check one)

  • Yes
  • No

The PR fulfills this requirement:

  • It's submitted to the dev branch, not the main branch

Other information:

Problem. form/Container.scss gives every direct child margin-bottom: 10px through
.neo-form-container > *. Two field roots set their own, different bottom margin at the same
specificity: .neo-textfield (5px, inherited by Number/Date/Time/etc.) and .neo-file-upload-field
(2.5rem, reserved for its error message). Since both rules tie on specificity, whichever stylesheet
the browser inserted last won. Neo inserts a class's stylesheet the first time an instance of that
class is created (insertThemeFiles() in src/worker/App.mjs), so the same form rendered with 5px or
10px of spacing depending on which component the app happened to create first — not on anything the
form's own markup controls.

Fix. Wrap the container's base rule in :where(), which contributes zero specificity, so a field's
own margin always wins:

 .neo-form-container {
     padding: 20px;
-
-    > * {
-       margin-bottom: 10px;
-
-        &:last-child {
-            margin-bottom: 0;
-        }
-    }
 }
+
+// A field's own margin wins; the container spaces only the children that set none.
+:where(.neo-form-container) > * {
+    margin-bottom: 10px;
+}
+
+.neo-form-container > *:last-child {
+    margin-bottom: 0;
+}

The last-child rule is untouched and keeps its (0,2,0) specificity, as called out in the issue.

Test. test/playwright/component/form/Container.spec.mjs builds the same three-child form (TextField,
FileUpload field, TextField) twice:

  • once after the form is the first thing created,
  • once after a standalone TextField and FileUpload field were created elsewhere first (so their
    stylesheets insert before the form's does).

Both assert the same getComputedStyle(...).marginBottom: 5px for the first TextField, the upload
field's own 2.5rem (computed in px) for the upload field, and 0px for the last child.

Before this fix, the second test fails on dev (first TextField reads 10px, reproducing the issue);
the first test happens to pass by accident of creation order. I verified this by temporarily reverting
the SCSS change and rebuilding the dev theme:

✓ a field keeps its own margin-bottom when its stylesheet loads after the container
✘ a field keeps its own margin-bottom when its stylesheet loaded before the container
  Expected: "5px"
  Received: "10px"

With the fix restored, both pass, and so does the rest of the form suite (37/37):

npm run test-components -- form
...
37 passed (29.2s)

Scope note on the "Done when" list's screenshot item. 11 files under examples/ and apps/ import
form/Container.mjs: examples/layout/form/MainContainer.mjs, examples/component/toast/MainContainer.mjs,
examples/component/process/realWorldExample/MainContainer.mjs, examples/container/fragment/MainContainer.mjs,
apps/form/view/FormPageContainer.mjs, apps/form/view/FormContainer.mjs, apps/legit/view/AddFileDialog.mjs,
apps/realworld2/view/user/LoginFormContainer.mjs, apps/realworld2/view/user/SettingsFormContainer.mjs,
apps/realworld2/view/article/DetailsContainer.mjs, apps/realworld2/view/article/FormContainer.mjs. I did not
launch each app to compare before/after screenshots — only a form whose app creates some other TextField-family
field before that form (so it was accidentally getting 10px today) would render differently, and the change
makes it 5px either way. I could not determine from a static read which (if any) of these apps hit that order,
so I'm flagging it rather than asserting none of them change.

Generated by Claude Code.

…load order

form/Container.scss gave every direct child margin-bottom: 10px and a field's own
rule (margin-bottom: 5px on .neo-textfield, 2.5rem on .neo-file-upload-field) tied
it at the same specificity, so whichever stylesheet the browser inserted last won.
Neo inserts a class's stylesheet the first time an instance of it is created, so
the same form rendered with different spacing depending on which component the
app happened to create first.

Wrap the container's base rule in :where(), which contributes zero specificity, so
a field's own margin always wins. The last-child rule is untouched and keeps its
(0,2,0) specificity.

Generated by Claude Code.
Copilot AI balanced review requested due to automatic review settings October 5, 2026 14:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@neo-opus-ada neo-opus-ada left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Micro-Review

Opening: Thank you, @Saadanjum0. Two things stand out: you proved the bug red-first on dev by reverting the SCSS and rebuilding the theme, and you flagged the screenshot item honestly instead of guessing. If you'd like to see more of the engine, set up the Engine from a clone (three commands), then open apps/workstation and drag a tab out of the browser window (why that works). Your agent needs neither our Knowledge Base nor our Memory Core: neo-agent-skills is on npm.

Class: contained. One selector change that #19230 prescribed, plus its component spec. The PR introduces no concept beyond what the ticket already decided.

Verdict: Request Changes

Glance: At head 2793c1dd, the SCSS is the ticket's decided fix:

  • :where(.neo-form-container) > * contributes (0,0,0), so .neo-textfield and .neo-file-upload-field at (0,1,0) win in any load order.
  • The untouched last-child rule keeps (0,2,0), and the one-line why is there.
  • CI is green (32/32), and no file outside the two the ticket names changed.
  • Your note on the 11 forms is enough for the screenshot item: a static read can't tell creation order, and the maintainers' post-merge check covers it.

I found one problem, in the spec. It reads getComputedStyle as soon as an element exists, but Neo doesn't wait for a class's stylesheet before it renders. insertThemeFiles asks the main thread to add the <link> and only counts the pending loads (countLoadingThemeFiles in src/worker/App.mjs). So a read can land before a stylesheet has loaded. In the second test that cuts the wrong way: on dev, the form's fields can read 5px from the already-loaded Text.css before Container.css arrives and turns them into 10px. The test that should catch the bug could then pass on the bug.

Findings:

  • Wait for the stylesheets before reading any margin. Add a helper and call it after each createNeoInstance(...) and its waitForSelector(...), before the first marginBottomOf(...):

    async function stylesheetsLoaded(page, names) {
        await page.waitForFunction(names => {
            const links = [...document.querySelectorAll('link[rel="stylesheet"]')];
    
            return names.every(name => links.some(link => link.href.includes(name))) && links.every(link => link.sheet)
        }, names)
    }
    
    // e.g. in both tests, once the form exists:
    await stylesheetsLoaded(page, ['/form/Container.css', '/form/field/Text.css', '/form/field/FileUpload.css']);

    A <link> has its sheet only once it has loaded, and theme files arrive as css/<folder>/<class path>.css (Stylesheet.addThemeFiles in src/main/addon/Stylesheet.mjs). A file that never loads makes the wait fail loudly instead of reading defaults. Then rerun your red check: on dev, the second test should still read 10px.

  • Origin Session ID: 5267f5db-e1d4-4297-8570-4981234133aa

⚖️ Ada · @neo-opus-ada · Claude Opus 5.5 · Claude Code


[review-budget-managed]

  • outcome: within-budget
  • ordinary-limit: 1
  • activation-issue: 15257
  • activation-pr: 15307
  • activated-at: 2026-07-16T20:54:31Z

getComputedStyle() could run before a field's or the container's theme
stylesheet had finished loading, since insertThemeFiles() only tells the
main thread to add the <link> and does not wait for it to load. On dev,
that let the second test read 5px from an already-loaded Text.css before
Container.css arrived, which could pass even if the margin-bottom fix
itself regressed.

Add stylesheetsLoaded(), which waits for every named theme file's <link>
to be present with a resolved .sheet, and call it before each test's
first margin read.
@Saadanjum0

Copy link
Copy Markdown
Contributor Author

Good catch, thank you — pushed f20f78f.

Added stylesheetsLoaded(): waits for Container.css, Text.css, and FileUpload.css to each have a <link> present with a resolved .sheet (not just for the field element to exist), and call it right before the first marginBottomOf() read in both tests. That closes the gap where getComputedStyle() could run before insertThemeFiles()'s stylesheet fetch actually finished.

Full form suite still green (37/37) with the wait in place.

@neo-opus-ada neo-opus-ada left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Micro-Review — Round 2

Opening: Thank you, @Saadanjum0. You turned the finding around in one commit, and the comment above the helper explains the race to the next reader.

Class: contained. One test-only commit on top of the reviewed SCSS fix.

Verdict: Approve

Glance: At head f20f78f1, the one finding from round 1 is resolved:

  • stylesheetsLoaded() waits until Container.css, Text.css and FileUpload.css each have a <link> and every stylesheet <link> has its sheet. Both tests call it before the first marginBottomOf(...).
  • Both tests create the full form, so all three stylesheets always get a <link> and the wait resolves. A stylesheet that never loads keeps its sheet at null, and the wait times out loudly instead of reading defaults.
  • The wait only moves the read until after the stylesheets have loaded. On dev, the second test now reads the cascade's final 10px and fails, where before it could read 5px early and pass on the bug. Your red-first check from round 1 still holds.
  • CI: lint, guard, the unit timezone jobs and the baseline checks are green. The components, e2e-engine, unit and build-all jobs were still running when I reviewed (the operator restarted them).

Findings: none.

  • Origin Session ID: 4095e966-9503-44c4-a759-5e7293dc3daa

⚖️ Ada · @neo-opus-ada · Claude Opus 5.5 · Claude Code

@tobiu
tobiu merged commit bebffaa into neomjs:dev Oct 6, 2026
31 checks passed
tobiu pushed a commit that referenced this pull request Oct 6, 2026
…drop three stale claims (#19431) (#19432)

Lines for #19403 and #19405 (the merged-cycle gap), #19412 (a first Engine PR) and #19426 (the Dock Layouts card). The claim-reply line gets its live bound after #19414. Line 190 follows #19408. The package chapter states that the package ships no browser bundles and a workspace builds them (#18823), and the DevIndex and site-deploy sentences no longer cite the closed #19047.
@github-actions

Copy link
Copy Markdown
Contributor

This pull request was merged before Neo.mjs kept a standing for its contributors. It does now, counted in merged pull requests, so here is yours.

Standing: Contributor (1 merged). 4 more reach Returning Contributor.

Your badge, if you would like to show it, is at https://neomjs.com/badges/saadanjum0-160885369/ with three forms to choose from. The card's line:
<a href="https://neomjs.com/badges/saadanjum0-160885369/"><img src="https://neomjs.com/badges/saadanjum0-160885369/card.svg" alt="Neo.mjs contributor"></a>

Next, if you want one: #20926 (also design) and #20679 (also design) are open and unclaimed; a comment on either claims it. The full list has more if both are taken by then.

See it run: the engine you changed runs live in the browser: https://neomjs.com/examples

This note comes once. From here on a comment arrives with each merge. To be left out of the records, open an issue in neomjs/pages.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A form field's bottom margin depends on which stylesheet loaded last

4 participants