MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-3522

15 3D / Immersive leaves 199 of 280 interactive controls flat — the physical-key rule matches `rounded-(--radius-btn)` only, so Plan with AI, Re-plan, the 3-dots menu and the roadmap search have no depth (motir-core)

Done
Description

Type · implementation defect (CSS selector coverage). Not a planning mistake — the card that shipped the style asked for exactly the right thing; the implementation bound "every button" to ONE utility class. No lesson owed.

Parent · parentless at the ROOT. Found out-of-band (manual dogfooding), so there is no finding story to carry a blocked_by edge into; the discovery subtask MOTIR-1052 and its whole ancestor chain (story MOTIR-804, Epic 7 MOTIR-464) are done, so the epic rung is disqualified and the ladder falls through to a root sibling. relates_to MOTIR-1052.

Discovered in · MOTIR-1052 (7.3.39 Style: 3D / Immersive), shipped in PR #1296. Reported by Yue while running data-style="3d-immersive" in the app.


Root cause / fix

[data-style='3d-immersive'] gives a control its "physical key" thickness through exactly two selectors, at packages/design-system/theme.css:1276-1277 (verified on origin/main @ 052965b3):

[data-style='3d-immersive'] .rounded-\(--radius-btn\),
[data-style='3d-immersive'] button.rounded-\(--radius-input\) { box-shadow: 0 3px 0 0 …; }

The rule keys on the compiled radius utility class, not on the element being interactive. The block's own comment shows the sweep that produced it: the author enumerated the ~35 hand-rolled rounded-(--radius-btn) <button>s and widened the rule to cover that class. Every control the app builds on a different radius utility was never in the sample, so it gets no box-shadow, no base edge, and no press-down — while the panel it sits on floats on a four-layer --shadow-card. That is exactly the "half-3D" failure docs/styles/3d-immersive.md §8 names as its first ❌ ("Leave buttons flat — a flat button beside floating 3D cards is the inconsistency that reads as 'half-3D'"), and it contradicts the shipped registry copy in packages/design-system/src/theme/styles.ts:314"PHYSICAL buttons with real thickness … Nothing is flat."

Measured (not estimated)

A JSX-opening-tag scan (regex over <button|<a|<Link|<input|<textarea|<select and the rounded-* utility in the same tag) across app/, components/ and packages/design-system/src at origin/main @ 052965b3 returns 300 tags carrying a radius utility — 280 of them interactive controls (excluding the 20 text fields):

radius utilitytagsreached by the rule?
rounded-(--radius-btn)59 (button 32 · Link 21 · a 6)✅ raised
button.rounded-(--radius-input)6✅ raised
rounded-(--radius-control)180 (button 143 · Link 23 · a 14)box-shadow: none
rounded-(--radius-badge)12 (button 8 · Link 4)box-shadow: none
rounded-full6box-shadow: none
rounded-sm1box-shadow: none
rounded-(--radius-card)16 (button 8 · Link 8)⚠️ floats on --shadow-card (panel rule) but never presses
text fields (input/select/textarea + the Input primitive's wrapper div)20 + primitive❌ no treatment of any kind

65 raised · 16 float-but-never-press · 199 with no depth at all.

Each control Yue reported, with its actual class

Computed box-shadow measured in headless Chromium against the real compiled token layer (@tailwindcss/postcss over packages/design-system/theme.css, data-style="3d-immersive" data-palette="motir"):

#controlfileradius classcomputed box-shadow
1"Plan with AI" pillcomponents/planning/PlanWithAILauncher.tsx:88rounded-(--radius-badge)its own inline hero glow only — no base edge
2"Building in public" header linkapp/(authed)/_components/build-in-public/BuildingInPublicHeaderLink.tsx:63rounded-(--radius-badge)none
3"Re-plan" entrance (item detail)components/planning/WorkItemPlanEntrance.tsx:123rounded-(--radius-badge)none
43-dots menu trigger (item detail)components/issues/actions/WorkItemActionsMenu.tsx:59 (MENU_TRIGGER_CLASS)rounded-(--radius-control)none
5"Search the roadmap" inputcomponents/planning/ProjectRoadmapCanvas.tsx:889packages/design-system/src/components/ui/Input.tsx:114div.rounded-(--radius-input)none
+"Plan with AI" floating orb (FAB)components/planning/PlanWithAIFab.tsx:136rounded-fullnone

Controls on rounded-(--radius-btn) measured in the same render for contrast: rgb(69,52,179) 0 4px 0 0, rgba(15,15,15,.32) 0 7px 12px -3px (primary) / rgb(200,196,190) 0 3px 0 0, … (neutral). Side-by-side render attached to this card's discussion.

Note on #2: the pre-public BuildInPublicButton (…/BuildInPublicButton.tsx) is on rounded-(--radius-btn) and is raised. The flat one is its post-public successor in the same header slot, the BuildingInPublicHeaderLink badge — and the placement="drawer" variants of both.

Three things a blanket widening would get WRONG — the reason this is not a one-line selector edit

  1. ~54 of the 180 --radius-control controls are menu / option ROWS inside popovers (WorkItemActionsMenu's ITEM_CLASS, combobox options, sidebar rows — w-full / role="menuitem" / role="option"). A 3px base edge on every row of an open menu is worse than flat. ~62 are standalone icon buttons that should be keys; the remaining ~64 need reading. The fix needs a hook that separates the two — a data- attribute on the primitives, or a structural exclusion under [data-surface='popover'] / [data-menu-surface] — not a bare class rule.
  2. The "Plan with AI" pill sets boxShadow in an inline style prop (HERO_STYLE, PlanWithAILauncher.tsx:59-69). An inline declaration beats any style-layer rule, so widening the selector alone leaves this one control unchanged. It needs the base edge folded into HERO_STYLE or a variable the style layer can re-point.
  3. The style block deliberately leaves --radius-pill untouched so avatars / dots / the spinner stay circular — but the FAB is a circular button. A rounded-full rule must raise the button without touching the non-interactive circles.

The text-field half is UNSPECIFIED, not deliberately flat

theme.css:1272-1275 scopes the input arm to button. explicitly "so a real <input>/<textarea> text field (same radius) is NEVER raised" — correct, a text field should not stick up. But nothing was put in its place: docs/styles/3d-immersive.md §4 says only "Input / control — On the face; generous rounded dimensional silhouette", and the shipped result is a 1px-bordered rectangle with box-shadow: none on a page where every panel carries a four-layer float. The skeuomorphic counterpart of a raised key is a recessed field (an inset shadow, pressed INTO the surface); §4 never decides that, so the fix has to amend the spec doc before it can implement it. Settle it in docs/styles/3d-immersive.md §4/§6a as part of this card — it is a two-line amendment to an existing design asset, not a new surface.


Acceptance criteria

  • docs/styles/3d-immersive.md §4's plane ladder states, per control family, whether it is a raised key, a recessed field, or deliberately flat — and §4 gains the text-field decision (recessed vs on-the-face) that it currently leaves unwritten. The §4 table and the [data-style='3d-immersive'] CSS agree row for row.
  • Under data-style="3d-immersive", getComputedStyle(el).boxShadow !== 'none' for each of the six controls named in the table above, measured on the real compiled token layer.
  • Menu and option rows (role="menuitem" / role="option", the w-full rows inside [data-surface='popover'] and [data-menu-surface]) still compute box-shadow: none — the widening must not raise them.
  • [data-variant='ghost'] still computes box-shadow: none; the quiet button stays flat.
  • The :active:not(:disabled) press rule covers the same set the resting rule does — every newly-raised control presses down onto its base, none is left with a resting edge and no press.
  • A guard test in tests/theme/ enumerates the radius utilities the codebase emits on interactive tags (the scan above, run in-test rather than hard-coded) and asserts each one is classified — raised, recessed, or flat-with-a-named-reason. A new radius utility appearing on a control with no classification fails the test. Assert the CLASSIFICATION, not a count: the tag counts in this card's table are a 052965b3 reading and will drift.
  • No --color-* / --el-* colour token is added or changed by the fix — the colour axis stays disjoint (the existing material-rule test still passes).
  • pnpm lint, pnpm typecheck and the theme suite are green on the branch.

Context refs

  • packages/design-system/theme.css:1258-1322 — the physical-key + press rules (the defect) and their comment describing the sweep that produced them.
  • packages/design-system/theme.css:1014-1077 — the [data-style='3d-immersive'] token block: --radius-btn: 14px, --radius-input: 14px, --radius-control: 12px, --radius-badge: 9999px, --radius-card: 20px.
  • packages/design-system/theme.css:1121-1125 — the global panel float on .rounded-\(--radius-card\), the pattern the button rule was modelled on.
  • docs/styles/3d-immersive.md — §4 plane ladder, §6a static depth, §8 ❌ "Leave buttons flat". The design asset this card amends.
  • packages/design-system/src/theme/styles.ts:314-340STYLE_REGISTRY['3d-immersive'], whose dimensions.components promises "PHYSICAL buttons … Nothing is flat."
  • packages/design-system/src/components/ui/Button.tsx:29,79 — the primitive: rounded-(--radius-btn) + the data-variant hook the filled/ghost overrides key on.
  • packages/design-system/src/components/ui/Input.tsx:110-128 — the field shell (data-surface="input", border only, no shadow in any style).
  • The six reported controls: components/planning/PlanWithAILauncher.tsx:59,88 · app/(authed)/_components/build-in-public/BuildingInPublicHeaderLink.tsx:63 · components/planning/WorkItemPlanEntrance.tsx:123 · components/issues/actions/WorkItemActionsMenu.tsx:59 · components/planning/ProjectRoadmapCanvas.tsx:889 · components/planning/PlanWithAIFab.tsx:136.
  • tests/theme/ — the existing theme suite; tests/theme/styleRegistry.test.ts is today the ONLY test that names this style, and it asserts the registry entry's name only. Nothing asserts the style's rendered coverage, which is why the gap shipped.

Resolution

Open.

Status
Done
Type
Bug