Skip to content

fix(web): bring design mode back in line with the fork's design system - #241

Merged
NoahHendrickson merged 4 commits into
customfrom
t3/audit-design-mode-sidebar
Oct 31, 2026
Merged

NoahHendrickson merged 4 commits into
customfrom
t3/audit-design-mode-sidebar

Conversation

@NoahHendrickson

@NoahHendrickson NoahHendrickson commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

Design mode's panel and layers rail had drifted from design-system changes made since the panel was built: a divider that didn't run full width, selected segments without Glass's chip finish, undersized icon buttons, and a spacing badge lit in the wrong color.

Changes

  • Screen-size menu divider runs edge to edge (-mx-1), per the full-width divider rule in .fork/AGENTS.md.
  • Selected segments under Glass (direction, wrap, and text-align strips) now get the same halo and glint as every other selected tab. The panel's SegmentField stamps SegmentedTab's data-fork-segmented-tab marker and data-pressed, so the existing selected-tab rule covers it with no new CSS. Its hairline is now an inset ring, which that rule replaces, so the segment's box doesn't change.
  • Layers rail header buttons (clear filter, collapse all, hide layers) are the ghost icon-xs Button wrapped in Tooltip, the same build as the show-layers toggle they swap with. A small RailIconButton helper does this. They get the 24px box and hover fill that icon-only controls in the chrome carry, and real tooltips instead of native title ones.
  • On-scale spacing badge lights in the design green instead of --primary, which is white in dark themes.

Two bugs found while filming the feature

  • The layers rail could fail to appear on enable. The engine emitted layers before state {active: true}, and the host's enable reset wiped it. The engine now re-sends the tree after state, as it already did for tokens and canvas. A browser test boots the real engine and asserts a layers message follows the state flip; it fails without the fix.
  • At the default right-panel width (~540px), the 325px design panel overflowed the window and clipped "Send to chat". While a visible preview tab has design mode on, it now asks the right panel for the room its chrome takes: the panel, plus the preview's chrome row (which clips its own controls below ~290px), plus the layers rail when shown. ChatView passes that to usePreviewPanelInlineSize as a minimum (fenced), capped by the hook's existing maximum so the chat column keeps its floor. The width you dragged to is never written, so turning design mode off restores it. Each preview tab publishes under its own key, so a hidden tab can't clear the visible one's request.

Verification

  • Design-mode unit and guard tests, forkGlassSurfaces, the cool-darker theme and vibrancy guards: 189 passing.
  • Targeted lint (no new errors) and web typecheck.
  • Checked live in the desktop dev app on Glass.

Reviewed with sol-loop: 1 round with GPT-6.1-Sol on high (scope: the layers-rail and panel-room fixes in d530d7d). Sol verdict: approved

Claude Opus 5.5 via Claude Code in T3 Code.

🤖 Generated with Claude Code

- The screen-size menu's divider runs full width (-mx-1), per the
  full-width divider rule.
- The panel's segment strips take Glass's selected-tab chip finish, like
  every other selected tab; the hairline gives way to the glass edge.
- The layers rail header buttons are the ghost icon-xs Button: the 24px
  box and hover fill every icon-only control in the chrome carries.
- The on-scale spacing badge lights in the design green rather than
  --primary, which is white in dark themes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:M labels Oct 30, 2026

@NoahHendrickson NoahHendrickson left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Code review of 9758b20d87f295af11202ac34f9733aa9966c52e

One finding, low severity, posted inline. Everything else checks out:

  • Screen-size divider: MenuPopup's scroll wrapper is p-1, and the separator sits right inside it, so -mx-1 runs it edge to edge.
  • Glass segment rule: it sits in the Full-only block next to the other selected-tab rules. It's unlayered, so border-width: 0 beats Tailwind's border utility. SegmentField has no ring utilities, so leaving out the --tw-ring-* shadows drops nothing. The guard and the manifest entry match.
  • Spacing badge: it uses the same --fork-design-accent / -bg pair as ScreenSizeMenu, CanvasControls and the other badges in DesignPanelFields.
  • Hide button: data-fork-design-layers-hide survived the swap to Button, so focusLayersControl(LAYERS_HIDE_BUTTON) still finds it.

Claude Opus 5.5 via Claude Code in T3 Code.

variant="ghost"
size="icon-xs"
type="button"
title="Clear filter"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

These three buttons still show native title tooltips. Button merges its props onto the <button> it renders, so title="Clear filter" / "Collapse all" / "Hide layers" reach the DOM unchanged. Removing the file's oxlint-disable only works because t3code/no-native-title-tooltip checks intrinsic elements (/^[a-z]/) and can't see through a component. So the PR description's line "They no longer use native title tooltips" isn't accurate.

It also breaks the pairing the PR is going for. The show-layers toggle these swap with (ForkPreviewLayersToggle.tsx) uses Tooltip > TooltipTrigger render={<Button variant="ghost" size="icon-xs" …/>} > TooltipPopup. So the rail's buttons now look like it, but their tooltip is the OS one. Either wrap each in the same Tooltip pattern and drop title (aria-label already names them), or put the disable comment back and fix the description.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 8f7fcbf. You're right: Button passed title through, so the OS tooltip was still there and the lint rule just couldn't see it. The three controls now go through a small RailIconButton helper: Tooltip > TooltipTrigger render={<Button variant="ghost" size="icon-xs" aria-label={label} …/>} > TooltipPopup, the same build as ForkPreviewLayersToggle. There's no title anywhere, and the description is corrected.

@NoahHendrickson NoahHendrickson left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Code-quality review of 9758b20d87f295af11202ac34f9733aa9966c52e.

The divider and spacing-badge fixes are fine. Two of the four changes need restructuring before this meets the bar:

  1. Design-panel segments under Glass. This adds a third copy of the selected-tab glass rule, plus a new marker, a manifest entry and guard assertions, all to dress a third hand-rolled segmented control. SegmentField should join the segmented-tab model that already exists, so this rule doesn't need to be added at all. The component's look is now also split across two files: the TSX declares the hairline and the theme sheet removes it.
  2. Layers rail header buttons. The lint exemption was removed by getting around the rule, not by fixing what it flags. Button passes title through to the native <button>, so all three controls still show native tooltips, and no-native-title-tooltip just can't see them through the component. The PR description says they no longer use native title tooltips, which is wrong. They also don't match the show-layers toggle they're meant to pair with.

The file sizes are fine (the glass sheet is at 758 lines). Details are in the inline comments. This is not an approval.

/* The design panel's segment strips are tabs on a field track too. Their
hairline goes: the glass edge replaces it, and a light drawn inside a
1px border would sit a pixel in from the edge. */
&.dark[data-fork-theme="cool-darker"] [data-fork-design-segment][aria-pressed="true"] {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This adds a third version of "selected tab is a glass chip". The box-shadow here is the same halo and glint pair as [data-fork-segmented-tab][data-pressed] just above it, and the comment calls these "tabs on a field track too". Each segmented control we write by hand now costs a marker, a rule, a manifest line and two guard assertions, and that keeps growing.

A simpler fix: build SegmentField on SegmentedTabs/SegmentedTab (the base-ui ToggleGroup/Toggle). Its mixed state becomes value={[]}, and selection comes from data-pressed instead of aria-pressed. Then the existing rule covers it, and this block, data-fork-design-segment, the manifest line and the new guard assertions can all go. SegmentedTab currently drops className, so it would need to accept the design panel's track and fill classes. That's a small change in a fork-owned file.

If that's too much for this PR, at least add this selector to the existing [data-fork-segmented-tab][data-pressed] rule so the halo and glint are declared once. As written, the next tweak to the tab glass has to be made twice.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Addressed in 8f7fcbf, with no new rule. I didn't rebuild SegmentField on ToggleGroup/Toggle in this PR: that also changes keyboard behaviour (roving focus, arrow keys), which is outside a styling pass. Instead it joins the model at the marker level. Each segment stamps data-fork-segmented-tab, and the selected one adds data-pressed, so the existing [data-fork-segmented-tab][data-pressed] rule is the only place the tab glass is declared. The added block, the data-fork-design-segment marker and its guard assertions are gone. The guard now pins the stamp in DesignPanelLayoutControls.

type="button"
title={option.title}
aria-pressed={selected ? "true" : "false"}
data-fork-design-segment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

With this PR, this line is no longer the full story. The selected segment declares border border-border here, and theme.custom.glass-surfaces.css removes it with border-width: 0 under Glass. Anyone reading SegmentField (and its JSDoc, "with a hairline border") can't tell that the border goes away on one theme, or that the content box grows 2px when it does.

If SegmentField moves onto SegmentedTab (see the CSS comment), this conflict goes away. If it doesn't, keep the border decision in one place, for example a design token for the selected-segment border that the Glass palette sets to transparent, so the theme sheet isn't undoing a utility class from another file.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 8f7fcbf. The selected segment's hairline is now shadow-[inset_0_0_0_1px_var(--border)] instead of border border-border. Under Glass, the shared selected-tab rule replaces that shadow with the chip edge, so no theme sheet undoes a class from this file and the segment's box is the same in every theme. The JSDoc says so.

variant="ghost"
size="icon-xs"
type="button"
title="Clear filter"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This doesn't actually remove the native tooltip. Button merges title onto the rendered <button>, so hovering still shows the OS tooltip. no-native-title-tooltip only checks intrinsic elements, so moving to <Button title> hides the problem from the lint rule without fixing it, and deleting the file's exemption looks like a fix when it isn't. The PR description ("They no longer use native title tooltips") is wrong as a result.

It's also inconsistent with the control this is meant to match. ForkPreviewLayersToggle wraps the same ghost icon-xs Button in Tooltip / TooltipTrigger render={...} / TooltipPopup, so the show and hide halves of the same toggle now behave differently on hover.

The three buttons also each repeat variant="ghost" size="icon-xs" type="button" title=X aria-label=X. type="button" is already Button's default, and the label is written twice per control. A small local helper removes all of that, and the explanatory JSX comment can go too:

function RailIconButton({ label, ...props }: { label: string } & ComponentProps<typeof Button>) {
  return (
    <Tooltip>
      <TooltipTrigger render={<Button variant="ghost" size="icon-xs" aria-label={label} {...props} />} />
      <TooltipPopup>{label}</TooltipPopup>
    </Tooltip>
  );
}

Then the header is just <RailIconButton label="Clear filter" onClick={...}><XIcon /></RailIconButton> and so on. That passes the lint rule for real, and these controls match the show-layers toggle.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 8f7fcbf, with the helper you sketched: RailIconButton sets aria-label from label, wraps the button in the same Tooltip as the show-layers toggle, and drops the redundant type/title props and the JSX comment. The design-mode guard now pins label=\"Hide layers\" plus aria-label={label} instead of the old literal.

@NoahHendrickson NoahHendrickson left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Reviewed commit 9758b20d87f295af11202ac34f9733aa9966c52e against base e63be3e7f92f64f7e095131a6ec6b39442ea3dda, after fetching PR #241 and verifying the isolated worktree's checked-out HEAD.

No actionable findings introduced by this diff. Checked the layers controls against the shared Button implementation, the menu separator against popup padding, and the selected-segment and spacing-badge styles against the fork's theme tokens and Glass settings.

Validation: 169 focused design-mode/theme tests passed across 17 files; targeted lint completed with no errors (warnings on unchanged code); web typecheck passed. No live browser or desktop visual pass was performed.

Review only; no approval or code changes.

…rule

Review follow-up on the design-mode alignment pass.

- The rail's header buttons rendered native title tooltips through
  Button's prop passthrough, which the lint rule cannot see. They now use
  the Tooltip pattern of the show-layers toggle they pair with, through a
  small RailIconButton helper.
- The panel's segment strip stamps SegmentedTab's marker and data-pressed,
  so the existing selected-tab Glass rule covers it. Its hairline is an
  inset ring that rule replaces, so no theme sheet undoes a border and the
  duplicate rule, marker and guard assertions are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 5.0 KiB 5.0 KiB 0 B (0.0%) 6.8 KiB ✅
Codex Thread snapshot wire 3.8 KiB 3.8 KiB 0 B (0.0%) 4.9 KiB ✅
Codex Live turn WebSocket wire 1.2 KiB 1.2 KiB 0 B (0.0%) 2.0 KiB ✅
Codex Live turn WebSocket decoded 20.9 KiB 20.9 KiB 0 B (0.0%) 29.3 KiB ✅
Codex Live turn messages 2 2 0 (0.0%) 8 ✅
Claude Total thread wire 5.0 KiB 5.0 KiB 0 B (0.0%) 6.8 KiB ✅
Claude Thread snapshot wire 3.8 KiB 3.8 KiB 0 B (0.0%) 4.9 KiB ✅
Claude Live turn WebSocket wire 1.2 KiB 1.2 KiB 0 B (0.0%) 2.0 KiB ✅
Claude Live turn WebSocket decoded 21.2 KiB 21.2 KiB 0 B (0.0%) 29.3 KiB ✅
Claude Live turn messages 2 2 0 (0.0%) 8 ✅

Baseline: e63be3e · PR result: 1a5800f · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 108.5 KiB
  • Claude decoded thread snapshot: 108.8 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

…s room

- The engine re-sends the layers tree after `state {active: true}`, like
  tokens and canvas already do. Its first emit lands before `state`, and
  the host's enable reset wiped it, so the rail could stay missing until
  the page next mutated. A browser test boots the real engine and checks
  a layers message follows the state flip.
- While a visible preview tab has design mode on, it asks the right panel
  for the room its chrome takes (panel + preview chrome row + layers rail
  when shown). ChatView passes that to the panel width hook as a floor,
  capped by the hook's own maximum. The user's dragged width is never
  written, so turning design mode off restores it. At the default width
  the 325px panel used to overflow the window and clip Send to chat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added size:L and removed size:M labels Oct 30, 2026
knip flags it as an unused export; only the panel and rail widths are read
outside designPanelRoom.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@NoahHendrickson
NoahHendrickson merged commit 6ec3228 into custom Oct 31, 2026
25 checks passed
@NoahHendrickson
NoahHendrickson deleted the t3/audit-design-mode-sidebar branch October 31, 2026 03:06
NoahHendrickson pushed a commit that referenced this pull request Oct 31, 2026
Brings in #240 (landing draft cleanup) and #241 (design mode fixes).
Upstream moved the right panel's width hook from ChatView into
ChatRightPanel, so design mode's panel-room floor now reaches it through
a fenced minWidth prop; manifest watch entry and guard follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NoahHendrickson pushed a commit that referenced this pull request Oct 31, 2026
The landing-draft header and a guard header were over four lines, the
panel-room effect had no stated reason, and the boot browser test cleaned
up in afterEach instead of a disposable. All four tripped the lint-owned
ratchet once custom merged in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants