Skip to content

WIP - style: flatten light-theme surfaces and unify elevation - #35

Draft
a-effort wants to merge 9 commits into
mainfrom
light-theme-shadow-cleanup
Draft

a-effort wants to merge 9 commits into
mainfrom
light-theme-shadow-cleanup

Conversation

@a-effort

Copy link
Copy Markdown
Contributor

Follow-up to #34. Light theme is flat by design: surfaces get no elevation, controls carry none in either theme, and floating layers keep theirs.

Why a token

Tailwind inlines shadow values at build time — only the color is a variable, and --tw-shadow-color is registered inherits: false — so there is no single place to switch shadows off per theme. src/index.css gains four utilities that read a variable instead:

token light dark
elevation-xs flat Tailwind's shadow-xs
elevation-sm flat Tailwind's shadow-sm
elevation-panel flat the form-panel shadow
elevation-control raised raised

Dark mode keeps the exact values the raw utilities used, so nothing shifts there.

They compose through --tw-shadow rather than setting box-shadow directly. This matters: ring-* builds its outline from the same box-shadow list, and .elevation-* is emitted after .ring-1, so setting the property outright erased the ring on every card in light mode.

Three rules

  • controls — no shadow in either theme: buttons, inputs, switches, select triggers.
  • surfaces — elevation-*: cards, form panels, toolbars, icon chips.
  • floating — untouched: popovers, dropdowns, dialogs, sheets, the five slide-over detail panels.

elevation-control is the deliberate exception. Flattening the selected chip of a segmented control loses what makes it read as selected, so it stays raised in both themes. All 13 segmented controls in the app now share it — previously they split across two shadow levels and a dark-fill variant.

Also here

  • Two forms aligned. The create-server form adopted the connect-MCP-server form's chrome: card border/padding/elevation, icon chip, heading and intro type, inputs, disclosure toggle, and footer buttons. That fixed a pre-existing bg-white text-black submit button that was invisible against the light card, and dropped five one-off hex literals (#141414, #2b2b2f, #55555c, #252529, #343438).
  • Placeholders on the token. 17 fields across the MCP server form and its auth panels moved from hardcoded neutral-400/neutral-500 to text-muted-foreground — more legible in both themes. ~12 overrides remain in the tools/resources/prompts forms; see the note below.
  • Disclosure toggles no longer fill. variant="ghost" carries aria-expanded:bg-muted, so Optional configuration and Advanced settings filled whenever expanded.
  • Add resources chip now uses the tool-add-icon tokens like its siblings, instead of a hardcoded gray.
  • CA certificate Upload button is ghost, dropping six hardcoded neutrals.
  • Two dead sidebar shadows. shadow-[0_0_0_1px_hsl(var(--sidebar-border))] resolves to hsl(oklch(...)) after the token migration, which is invalid — that outline has not been rendering. Replaced with ring-*.

Testing

2841 unit tests pass; tsc --noEmit, eslint, and prettier --check clean. One test needed updating: input.test.tsx asserted the input carries shadow-xs.

The utilities were verified against the emitted CSS — light resolves to 0 0 #0000, dark to the original Tailwind values byte-for-byte, and all four variant forms (aria-pressed:, peer-checked:, data-[state=active]:, data-[state=checked]:) compile.

This is a visual change and the tests do not cover it. Both regressions caught during development — the ring clobbering above, and a card that went white in dark mode — were found by eye, not by CI. Worth a pass through both themes before merge.

Not included

  • Form-card fills. Giving the eleven form panels their own surface colour, and retargeting --card (which currently equals --main exactly, so bg-card gives no separation from the page). Parked on form-card-fill; needs rework.
  • ui/input and ui/textarea disagree on placeholder colour — the token vs. hardcoded neutrals, running opposite to each other. ~12 per-form overrides work around it. Fixing it in textarea.tsx is one line but touches every textarea.
  • 47 now-dead shadow-none overrides, left over from cancelling shadows the primitives no longer have.

The light theme is flat by design: surfaces get no elevation, and
controls carry none in either theme. Tailwind inlines shadow values at
build time — only the color is a variable, and --tw-shadow-color is
registered inherits: false — so there is no single place to switch them
off per theme. Add elevation-xs, elevation-sm and elevation-panel
utilities that read a variable instead: flat in light, the values the
raw Tailwind utilities used in dark, so nothing shifts there.

Three rules now hold:

  controls  no shadow in either theme. Buttons, inputs, switches, and
            select triggers are flat; segmented controls that used
            shadow-sm as their only selected-state signal get
            ring-1 ring-border, which reads in both themes.
  surfaces  elevation-*. Cards, form panels, toolbars, icon chips.
  floating  untouched. Popovers, dropdowns, dialogs, sheets and the
            slide-over detail panels overlay the page and need the
            separation in both themes.

The utilities compose through --tw-shadow rather than setting box-shadow
directly, because ring-* builds its outline from the same box-shadow
list; setting the property outright made elevation-* win on cascade
order and erased the ring on every card in light mode.

Also replaces the two sidebar menu outlines drawn as
shadow-[0_0_0_1px_hsl(var(--sidebar-border))] with ring utilities. Those
resolved to hsl(oklch(...)) after the oklch token migration, which is
invalid, so the outline they intended has not been rendering.

Signed-off-by: Anna Effort <anna.effort@ibm.com>
Signed-off-by: Anna Effort <anna.effort@ibm.com>
…-server card

Signed-off-by: Anna Effort <anna.effort@ibm.com>
…aceholders

Signed-off-by: Anna Effort <anna.effort@ibm.com>
Signed-off-by: Anna Effort <anna.effort@ibm.com>
Signed-off-by: Anna Effort <anna.effort@ibm.com>
Signed-off-by: Anna Effort <anna.effort@ibm.com>
Signed-off-by: Anna Effort <anna.effort@ibm.com>
Signed-off-by: Anna Effort <anna.effort@ibm.com>
@a-effort a-effort changed the title style: flatten light-theme surfaces and unify elevation WIP - style: flatten light-theme surfaces and unify elevation Aug 15, 2026
@a-effort a-effort self-assigned this Aug 19, 2026
@a-effort

a-effort commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Note from work while building the pending-invitations chip for IBM/mcp-context-forge#5536... components for that all inherit shadow-xs from the outline variant, so each needed a shadow-none at the call site. That can be removed when this work is merged.

a-effort added a commit that referenced this pull request Sep 11, 2026
Measured against the Figma frame: 8px padding on all four sides with a
content-driven height, 4px corners, and an explicit foreground on the
label, which was inheriting its colour.

The icon square is 17.5px rather than the drawn 20px. At 20px the chip
stands 38px tall, and the Settings tab row reserves 32px for its
toolbar contents, so the drawn size pushes the whole row taller.

The three outline buttons opt out of the variant's shadow-xs, which
reads as an unwanted shadow in light mode. Removing it at the variant
belongs to #35, so these join the
dead overrides that PR already sweeps up.

Signed-off-by: Anna Effort <anna.effort@ibm.com>
vishu-bh pushed a commit that referenced this pull request Oct 1, 2026
* Add a team icon background token

The teams table and the members dialog each hardcoded bg-yellow-500.
The invitations dialog needs the same square, so give it a token named
for the domain rather than shipping a third copy.

Value is #ffd200 from the design, not yellow-500's #eab308, so the
existing squares change shade slightly. No .dark override, for the
reason the other icon-bg tokens already state.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Add the invitations API module

Transport for the invitee's own invitations, for #5536.

Two of the three routes do not exist yet and are the subject of #6010,
which also leaves the inbox path undecided. Both unknowns are confined
to this module, so settling them is a one-line change here.

TeamInvitation re-exports the generated TeamInvitationResponse rather
than restating it, so regenerating the client turns any drift between
what this assumed and what the backend ships into a compile error.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Add the invitations message bundle

Its own bundle rather than keys in teams.json, since the components
consuming it sit outside components/teams and a later surface
elsewhere in the app should not reach into the teams bundle.

The invitation sentence is one message with named placeholders so
translators control word order, and the count uses ICU plural.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Add the pending invitation row

One invitation as a sentence plus either its two actions or the
outcome that replaced them.

Accepted and declined are deliberately asymmetric, per node
5966:29865: accepted gets a check and full contrast, declined is muted
with no icon. Roles map to display names with a fallback to the raw
string, so an unrecognised role stays readable.

Renders a div rather than an li, so a surface showing one invitation
inline is not forced into list markup.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Add the pending invitations dialog

Presentational: it takes the list and callbacks and owns no fetching.

Once nothing is left to act on it holds the confirmation for 4000ms
and closes itself, reusing sonner's default duration so the app has
one dwell rather than two. Pointer entry cancels the timer outright
rather than pausing it, since a timer that resumes on pointer exit
closes at an unpredictable moment. Radix's usual pause-while-focused
signal is unusable because the dialog traps focus.

Scroll is scoped to the list rather than the dialog. DialogContent's
own overflow puts the close button inside the scroll container, so
past roughly seven invitations the X scrolls out of reach.

Resolving a row unmounts the focused button, so focus moves to the
next actionable row or to the close button, which is what the new
data-slot on the primitive is for.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Add the pending invitations data hook

Resolutions are committed after the server confirms, not
optimistically: both actions are irreversible and the dialog stays
open, so a rollback would visibly flip a row back.

Named Data to keep it distinct from the context consumer of the same
idea. An accidental second instance holds its own resolutions, which
is the divergent count the provider exists to prevent, so the two
should not be one import typo apart.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Add the pending invitations provider

One provider, one dialog, N triggers. Two mounted copies of a
self-contained component would hold separate resolutions, so resolving
in one would leave the other's count stale with nothing logged.

Mounted in AppShell, which an app-wide fetch would not justify on its
own. The fetch is gated on a mounted consumer instead, so a page with
no trigger issues no request. That gating is also what lets this land
ahead of #6010 with no feature flag: nothing calls the missing routes
until a trigger is rendered.

Focus return is handled here rather than by a DialogTrigger, which
would force every trigger into one dialog's subtree. The element that
opened the dialog is commonly gone by close, since resolving the last
invitation unmounts every trigger.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Add the pending invitations chip

The Settings > Teams trigger, hidden at zero pending.

Not yet rendered anywhere. Mounting it registers the consumer that
starts the fetch, so the Teams page wiring waits on #6010.

Takes the height and padding of the Create team button it will sit
beside rather than the design's standalone 36px chip.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Apply design review to the invitations chip

Measured against the Figma frame: 8px padding on all four sides with a
content-driven height, 4px corners, and an explicit foreground on the
label, which was inheriting its colour.

The icon square is 17.5px rather than the drawn 20px. At 20px the chip
stands 38px tall, and the Settings tab row reserves 32px for its
toolbar contents, so the drawn size pushes the whole row taller.

The three outline buttons opt out of the variant's shadow-xs, which
reads as an unwanted shadow in light mode. Removing it at the variant
belongs to #35, so these join the
dead overrides that PR already sweeps up.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Mount the invitations chip on the Teams page

The step this feature could not land earlier. Mounting the chip is what
registers a consumer, which is what enables the fetch, so this is the
only change that calls the routes IBM/mcp-context-forge#6010 has yet to
add. Everything before it merges inert.

Teams reacts to an accepted invitation through a counter in context
rather than a callback prop, so a later surface elsewhere in the app
can react independently of whichever one rendered the trigger.

e2e/invitations.spec.ts stubs the three routes through the api-mock
fixture, one test per visual state. Under E2E_REAL_API=true it becomes
the integration test, once e2e/seed/seed.ts can seed a pending
invitation for the test user.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Trim the invitations comments

Cut the build-time narrative: which routes did not exist yet, which
alternatives were weighed, why a structure was chosen over another.
That belongs in the PR and the issues, and goes stale in the code.

What is left says what a reader needs to work with it: mount one hook
instance, the row is not an <li>, the dwell never resumes, focus return
is manual because the opener is usually gone.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Apply design review to the invitations dialog

Corners at 12px. The sentence and the action row lose their 16px
inset, so both sit flush with the header against the dialog's own
padding.

Roles render lowercase, matching teams.json, with the English article
inside the value: "an owner", "a member". pt-BR and es-ES carry no
article, since "como proprietário" is right and "como um proprietário"
is not.

The resolved row keeps the height of the buttons it replaces, so
accepting or declining no longer shifts the invitation below it, and
the accepted check uses the success token.

The list no longer scrolls. scrollbar-gutter-stable goes on
DialogContent, which is a scroll container through the primitive; the
property does not inherit, so it needs its own.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Cover the Teams page invitations wiring

The chip renders when invitations are pending and not otherwise, and
the teams list refetches when acceptedCount increments.

The refetch is what puts a newly joined team in the table, and it had
no coverage at all.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Drop a scratch test committed by mistake

It was debug instrumentation for the HeaderProfileMenu scroll lock, with no
assertions, and it failed both lint and format:check.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Apply review fixes to the pending invitations UI

The dwell now cancels on pointer movement or a key press during it, rather
than on pointer entry: resolving the last invitation means clicking inside
the dialog, so entry alone cancelled the dwell for every mouse user.

The accept button's accessible name now contains its visible label in all
three locales, so voice control can address it.

The load error gets a localised headline over the sanitised detail, the
pattern the Teams page already uses. sanitizeError only returns English.

The chip moves out of the has-teams branch, so someone invited to a first
team can still reach the invitation.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Drop expired invitations and announce each resolution

Expired invitations are filtered as they arrive. The inbox filters on
is_active, which stays true past the expiry date, so they would otherwise be
counted and offered a Join that cannot succeed.

The dialog no longer stays open with nothing in it: a refetch that comes back
empty closes it, and open() no-ops at a count of zero. An error keeps it open
so the retry stays reachable.

The sr-only region now names the row in flight and each outcome as it lands,
rather than only reporting once everything is resolved.

The toolbar no longer renders empty scaffolding, and the chip drops its
trailing margin when it has no siblings.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Fix three e2e assertions that had never been executed

A role=status count of zero can never hold: HeaderQuickNav mounts a permanent
announcer on every page. An open modal puts the chip in an aria-hidden subtree,
so the role query has to opt into it. Outcome text now also appears in the
announcer, so the row assertions are scoped to the row.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Address Copilot review on the invitations dialog

A refetch answered after the user acted no longer publishes: it would drop the
row they just resolved along with its confirmation. The dialog also stays open
while a request is in flight, so its outcome can be shown.

The sr-only summary counts what is still pending rather than the original
total, which it was calling pending after rows resolved.

The post-accept teams refresh catches its rejection. useQuery.execute rethrows
after recording the error, so the bare call leaked an unhandled rejection; the
other refresh paths in the page already catch.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Move the invitations badge into the header

Settings lists the Teams tab only for is_admin and redirects everyone else off
/app/settings/teams, so the badge was unreachable by the invitees it is for.
The header renders on every authenticated page for every role, and the provider
already wraps it.

The badge itself is unchanged. The Teams toolbar goes back to what it was, and
createTeamRef goes with it, since it existed only as the badge's focus fallback.

The e2e suite missed this because its default user is an admin, so the new case
signs in as a non-admin.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Restyle the invitations badge for the header

Drops the box from 38px to 28px: the h-auto override is gone, so size="sm"
applies, with 6px of vertical padding and a 16px icon square to match. The
half-pixel sizes go with it.

Team icon chips move to the brand cyan, which --server-icon-bg already
carries. That recolours the Teams table and the manage-members dialog too,
both of which moved onto this token earlier in the branch.

The count names the entity, since the badge is now app-wide rather than on
the Teams page.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Refresh the inbox on focus and announce every transition

The list was fetched once per session: the mount effect is the only automatic
load, and the trigger hides at a count of zero, so an empty first response left
no way to discover a later invitation. It now refetches when the tab regains
focus, if the list is more than a minute old. Expiry is rechecked against
expires_at rather than trusting the server's is_expired from load time.

The announcer took one in-flight entry and one newly resolved id, so a second
concurrent action was never announced, the first outcome was suppressed while
the second ran, and batched resolutions lost one. It now covers every row.

Invitation fixtures move to a relative expiry, which the date check would
otherwise have made stale on 2026-09-17.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Address review feedback on the invitations UI

Pause the staleness refresh while the dialog is open, so a list landing
behind it cannot drop a committed resolution and its confirmation.

Skip busy rows when moving focus after a row resolves: their buttons are
disabled, so focusing one leaves focus on the body.

Seed the post-accept team refetch on mount, in Teams and now also in
TeamSwitcher, which was never refreshed at all.

Move the TeamForm icon onto the team token the rest of the page uses.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* Clear the loading flag on unregister and accumulate announcements

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* fix: Drop lapsed invitations on a timer and recheck expiry on accept

The expiry sweep runs while the dialog is closed, so a tab that is never
blurred stops reporting lapsed invitations as pending. It refilters state
rather than refetching. Accept rechecks expiry at the click, covering the
open dialog where the sweep is paused. Decline stays unguarded.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* fix: Stop the expiry-click refetch clobbering a sibling confirmation

Accept on a lapsed row showed the toast and refetched. The server drops
an invitation already accepted in the same dialog, so the fresh list
took that row and its confirmation with it, emptying the dialog and
triggering the auto-close. The toast alone is the response now.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* fix: Hide the invitations retry once every row is resolved

Signed-off-by: Anna Effort <anna.effort@ibm.com>

* chore: refresh openapi.json to API v1.0.11+aac1bf

Signed-off-by: Anna Effort <anna.effort@ibm.com>

---------

Signed-off-by: Anna Effort <anna.effort@ibm.com>
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.

1 participant