Skip to content

feat(config-ui): reskin phase 1, design foundation and src/ui - #87

Merged
ubaidkhalidarbisoft merged 17 commits into
arbisoft:feat/config-ui-reskinfrom
muzamilkm:feat/config-ui-reskin-p1-foundation
Oct 12, 2026
Merged

ubaidkhalidarbisoft merged 17 commits into
arbisoft:feat/config-ui-reskinfrom
muzamilkm:feat/config-ui-reskin-p1-foundation

Conversation

@muzamilkm

@muzamilkm muzamilkm commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Phase 1 of the config-ui reskin. It builds the design system that the later phases use: theme tokens, brand config, the Figtree font, the src/ui component library, and a dev-only /ui-kit route that shows every component.

Theme and brand

  • Theme tokens.
    • src/theme/palette.ts holds the pure colour values for both modes. It has no imports, so e2e can import it.
    • scales.ts holds space, radius, shadow, layout, z-index, motion and typography.
    • antd-config.ts maps everything onto antd, including the component tokens.
    • tokens.ts keeps upstream's colour objects untouched and overrides them from one fork block.
    • styled.d.ts types the whole theme for styled-components.
  • Error text uses errorActive (#CF1322). #F5222D is 4.08:1 on white, below AA, so it stays for fills, borders and icons only. That covers antd colorErrorText and StatusBadge.
  • Status text tokens (successText, warningText, infoText) are added because antd's defaults were 2.0–4.1:1.
  • src/config/brand.ts is the only reader of the four DEVLAKE_* brand variables.
    • It replaces 11 scattered reads.
    • The BRAND_NAME default is now "Arbisoft DevLake", so the browser tab title changes.
    • DEVLAKE_COLOR_CUSTOM now also drives link, focus ring and icon colours, with hover and active shades from generate().
  • @ant-design/colors is now a direct dependency at the version antd already resolves (^8.0.1). The lockfile changes by one line, and no new package comes in.
  • Figtree, self-hosted: 400/500/600/700 woff2 (latin) from @fontsource/figtree@5.3.0, with the OFL text in src/fonts/OFL.txt.
  • index.css no longer has hex values.
    • First-paint fallbacks are system colours (Canvas/CanvasText), so a dark OS gets no white flash.
    • There is a global prefers-reduced-motion rule.
  • Brand assets. The sidebar shows the Arbisoft wordmark, rendered white, with "Powered by Apache DevLake" under it. The collapsed rail shows the Figma diamond mark.

src/ui

  • Shared modules: types.ts, constants.ts (COMMON_COPY, LIST_PARAMS, STATUS_TONE, SORT_ORDER) and utils.ts (toUserMessage, formatRelativeTime, formatDateTime).
  • Hooks, all unit-tested: useListState (URL-backed), useRouteTab, useDocumentTitle, useInView, useSidebarCollapsed, useConcurrencyQueue.
  • Leaf components: PluginIcon, StatusBadge, ExternalLink, IdentityCell, KeyValueList, CodeBlock, EmptyState (with 4 themed illustrations), MetricTile, PipelineProgress, SearchInput, FilterTabs, SortSelect, Toolbar.
  • Composite components: PageHeader, SectionCard, DataTable, ConfirmModal, FormModal, DetailDrawer, RouteTabs, ConnectionHealth, IntegrationCard, ProgressBanner.
  • Shell components: BrandBlock, AccountBlock, SidebarNav, PageFooter, AppShell.
  • SidebarNav is antd Menu. It uses inlineCollapsed for the 72 px rail with tooltips and triggerSubMenuAction="click" for the rail flyout. It is styled through the Menu component tokens.
  • Every component follows the shared component standards:
    • no style prop, no colour literal, no any;
    • copy in COPY;
    • an accessible name on each interactive element;
    • a visible focus ring.

/ui-kit

  • Route: /ui-kit is registered only when import.meta.env.DEV and loaded with lazy(). grep -r ui-kit dist/ finds nothing, so it never ships.
  • Contents: one section per component and hook, showing every applicable state (empty, loading, error, disabled, danger, collapsed rail and flyout, long text and overflow). A light / dark / system switch sits at the top.

Plugin icons

  • PluginIcon swap: plugins/components/{connection-list,connection-select,plugin-name} now render plugin icons through PluginIcon, so they take the brand tint.
  • Shared component cleanup: those three upstream-owned files follow the shared component standards. Inline styles moved to styled.ts, copy moved to COPY, the empty interface is fixed, and the visible text is byte-identical.
  • Not touched: src/features/connections/components/name.tsx. src/features stays stable until phase 4.

Regression proof

Count Phase 0 baseline Phase 1
Tests 60 61
Expects 450 456
Backend assertions 66 66
Skips 7 8

E2E, state B, on a stack rebuilt from this branch:

Run Passed Skipped Failed
Phase 0 baseline 64 2 0
Full suite, after the first three commits (twice) 64 4 0
Full suite on the final head 332af3ebb, run by the orchestrator 64 4 0
ui-kit.spec.ts against yarn start (twice) 2 0 0
connections/catalog.spec.ts, --repeat-each=5 10 0 0
  • Skips: the 2 new skips are ui-kit.spec.ts, which runs only when E2E_UI_KIT_URL points at a yarn start server.
  • Goldens: every existing golden matches, and none changed. The new spec sends no writes, so it has 2 golden files with writes: [].
  • Test diff by class: additions only. There are no locator, navigation or expectation changes.
    • New spec: ui-kit/ui-kit.spec.ts, with the UiKitPage page object.
    • shell/theme.spec.ts: it now also asserts that the computed body background equals PALETTE[mode].colors.bgContainer, imported from src/theme/palette.ts.
    • Census baseline: additions only.
  • Capability walk: not applicable, because no screen was rebuilt. The only visible changes app-wide:
    • colours and the Figtree font;
    • the "Arbisoft DevLake" tab-title default;
    • the plugin-icon tint in the three plugin components.
  • One unexplained failure. During the subagent's run right after a container rebuild, connections/catalog.spec.ts:57 failed once. It passed alone, on a rerun of its group, in the full run on the final head, and in 10 of 10 repeats, so it is recorded as a one-off.

Test plan

  • yarn lint:check: 0 errors; warnings 20 → 19.
  • yarn test: 35 files, 197 tests (phase 0: 16 / 91). New suites:
    • tokens: every key in both modes, WCAG contrast pairs computed in the test, and the brand override;
    • brand;
    • the 6 hooks and utils;
    • SearchInput, FilterTabs, DataTable, SidebarNav, RouteTabs, ConnectionHealth, IntegrationCard, ConfirmModal, FormModal.
  • yarn build, and grep -r ui-kit dist/ finds nothing.
  • yarn install --immutable.
  • yarn e2e:census: OK.
  • yarn quality:baseline: OK. The baseline went down by one: the no-empty-object-type warning was fixed.
  • lint:quality: 0 errors in every file created or substantively edited.
  • knip: 0 findings.
  • find:duplicates: 119 clones / 2,083 lines, unchanged, with no clone in new code.
  • E2E state B and the /ui-kit spec, as above.
  • /ui-kit checked in Chromium at 1440 and 1024, light and dark, against the Figma nodes:
    • 0 console errors or warnings;
    • keyboard: Tab and arrow keys in the sidebar, the rail flyout on click, Esc closes the flyout, modal and drawer, and the focus ring shows.
  • yarn npm audit (advisory): not available, because the registry returns 404 NOT_IMPLEMENTED for the audit endpoint.

Notes for reviewers

  • Commits (on top of phase 0's 9bc598045):
    1. 82f5e16d8: theme tokens, brand config and Figtree.
    2. 2a9f4aad1: src/ui base, hooks, leaf components and the /ui-kit route.
    3. 06980b719: composite and shell components, with their demos.
    4. f76692359: review fixes (rail icon contrast, placeholder fixture names).
    5. 64aac596a: e2e.
    6. 332af3ebb: shared component standards pass on the three touched plugin components.
    • Also: c0e130505 (Node 24.19.0 in .tool-versions), and 2423c667a and 0ce0f113f (LINT_TOOLING_SETUP.md command reference, plus a knip note).
  • Design and accessibility adjustments. Figma fidelity or contrast requirements led to these choices:
    • light selectedBg is #D1CCFF, to match Figma;
    • drawerWidth is 600, not 560, matching Figma's activity drawer;
    • form modal widths are 520 and 600, from the frames; lg (800) is a guess;
    • toolbar controls are all 40 px, as in the Figma list frames.
  • Design choices retained despite Figma differences:
    • hover is #685DA6 (Figma #5B4FBF);
    • control height is 32 (Figma 31);
    • button weight is 400 (Figma 500);
    • the modal title is 16/24 (Figma 18/26).
  • Mechanical touches, cleaned in their owning phases. Six route files received only the brand-import swap. Each is rebuilt later, where it gets a full shared component standards pass:
    • layout.tsx: phase 2;
    • connection.tsx: phase 4;
    • project detail, the blueprint detail page and blueprint connection detail: phase 5;
    • onboarding: phase 6.
  • Upstream coupling:
    • tokens.ts keeps upstream's colour objects verbatim; all fork values sit in one block after them;
    • three src/plugins/components/* files changed, with styles moved to new styled.ts files;
    • vitest.config.ts gains svgr();
    • src/app/router.tsx gains the dev-only route.

@ubaidkhalidarbisoft
ubaidkhalidarbisoft merged commit 1d8106e into arbisoft:feat/config-ui-reskin Oct 12, 2026
6 checks passed

@ubaidkhalidarbisoft ubaidkhalidarbisoft 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.

Reviewed Phase 1's own commits by reading the code (I didn't run anything). Nothing blocks the merge. The two DataTable comments are the ones worth fixing before later phases start using it; the rest are smaller.

return { ...column, sortOrder: active ? sortOrder : null };
});

export const toSortState = <T extends object>(sorter: SorterResult<T> | SorterResult<T>[]): SortState | undefined => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorting only works for columns that set key. withSortOrder finds the column by key, falling back to dataIndex, but antd's SorterResult.columnKey is set from column.key only (columnKey: column.key in antd's useSorter). So a column with sorter: true and only a dataIndex shows as sortable, but clicking it calls sort.onChange(undefined), and sorting never happens. The tests and the demo don't catch it because every column there has a key.

Fix: fall back to first.field, as withSortOrder does, or make key required on sortable columns in DataTableProps.

scroll={{ x: 'max-content' }}
locale={{ emptyText: firstLoad ? null : <EmptyState {...empty} /> }}
pagination={
pagination && {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

pagination is optional, but when it's left out, pagination && {...} passes undefined, and antd then pages the rows itself, 10 per page. A caller that leaves it out to get "no paging" will get pages of 10. Suggest pagination ? { ... } : false.

columns={sortedColumns}
dataSource={dataSource}
rowKey={rowKey}
loading={firstLoad}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only the first load shows a spinner. When the page, sort or filter changes, the old rows stay on screen with no loading sign until the new data arrives. If that's intended, please add a comment saying so; otherwise a lighter loading state on refetch would help.

return { hover: shades[SHADE_HOVER_INDEX], active: shades[SHADE_ACTIVE_INDEX] };
};

const withBrandOverride = (colors: AppThemeColors, custom: string): AppThemeColors => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With DEVLAKE_COLOR_CUSTOM set, link, focusRing and iconBrand get the same colour in both modes. The built-in palette uses a lighter link colour in dark mode (#9D93E0) for contrast, but with a custom colour, a dark brand colour gives dark links and focus rings on a dark background. The contrast tests in tokens.test.ts don't cover the override. One fix: derive the dark-mode shades with generate(custom, { theme: 'dark', backgroundColor: ... }).

: (defaults.pageSize ?? DEFAULT_PAGE_SIZE),
keyword: params.get(LIST_PARAMS.KEYWORD) ?? defaults.keyword ?? '',
sort: sortBy
? { sortBy: sortBy as S, sortOrder: isSortOrder(sortOrder) ? sortOrder : SORT_ORDER.DESC }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Two small things:

  • When defaults.sort is set, setSort(undefined) removes sortBy from the URL, and this line then falls back to the default. So clicking a column a third time (antd's "no sort" step) jumps back to the default sort. That might be fine, but it should be decided on purpose.
  • sortBy from the URL is cast to S without checking it, so any string can reach an API call. An optional allowedSorts list in the defaults would close that.

{title}
</TitleRow>
}
onCancel={onCancel}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ConfirmModal blocks Esc and clicking outside while loading (keyboard={!loading}, mask={{ closable: !loading }}), but FormModal lets the user close it in the middle of a submit. Suggest the same guard here. The button order also differs (Submit then Cancel here, Cancel then Confirm in ConfirmModal). If that follows the Figma frames, fine.

const promise = new Promise<void>((resolve) => {
waiting.current.push(() => {
void (async () => job())()
.catch(() => undefined)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Job errors are caught here and the promise resolves with nothing, so callers can't tell a failed job from one that worked. Queued jobs also keep running after the component unmounts. Either is fine if it's on purpose, but please say so in the comment at the top of the hook.


import { BRAND_NAME } from '@/config/brand';

const TITLE_SEPARATOR = ' · ';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor: this uses · as the separator, while the existing Helmet titles (layout, connection, and so on) use -. Once both are used together they'll also both set document.title. Worth deciding which one owns the title before phase 2.

brandName: env.DEVLAKE_BRAND_NAME || DEFAULT_BRAND_NAME,
titleCustom: env.DEVLAKE_TITLE_CUSTOM || undefined,
primaryOverride: env.DEVLAKE_COLOR_CUSTOM || undefined,
copyrightHide: Boolean(env.DEVLAKE_COPYRIGHT_HIDE),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor, and the bug was there before this PR: env values come in as strings, so DEVLAKE_COPYRIGHT_HIDE=false gives Boolean("false") === true and hides the footer. Now that this file is the only reader, it's a good place to parse it properly (=== 'true' || === true).

infoText: '#0958D9',
warningBg: '#FFF7E6',
infoTintBg: '#E6F7FF',
infoBg: '#E6F7FF',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor: the light palette sets infoBg and the dark one doesn't. It still works because the dark base colours provide it, but the two lists don't match.

@ubaidkhalidarbisoft

Copy link
Copy Markdown

Automated React review using the react-review Claude Code skill (#101). Scope: this PR's own commits only (stacked changes excluded). Findings are model-generated; verify before acting, especially items tagged (verify).

React Review: PR #87 — reskin phase 1, design foundation and src/ui (pr-86..pr-87, config-ui/src)

Coverage

  • Scope: 216 production files (non-test .ts/.tsx plus index.css) changed in pr-86..pr-87, read from a worktree at pr-87. Binary assets (svg/png/woff2/OFL) and tests are out of scope.
  • Read in full (209):
    • all 145 files under src/ui/ (components, hooks, types.ts, constants.ts, utils.ts, style-helpers.ts, barrels);
    • all 46 files under src/routes/ui-kit/ (for the demos I left out the import lines when reading);
    • src/theme/* (5), src/config/* (3), src/index.css;
    • the 8 src/plugins/components/{connection-list,connection-select,plugin-name} files (full file plus diff).
  • Diff hunks only (7): app/router.tsx (the hunk plus the router array around devRoutes), routes/layout/layout.tsx, routes/project/detail/index.tsx, routes/onboard/index.tsx, routes/connection/connection.tsx, routes/blueprint/detail/blueprint-detail-page.tsx, routes/blueprint/connection-detail/index.tsx. These changes are mechanical brandName → BRAND_NAME swaps.
  • Context:
    • The ESLint flat config (eslint.config.mjs) already enforces these rules on src/ui|theme|config|routes|app: no style prop, no colour literals, no any, and exhaustive-deps. jsx-a11y runs only in the report-only quality config.
    • React 19.3 and antd 6.6.4. No React Compiler.
    • useConfirmFlow and useModalForm do not exist at this PR.

Summary: This is a solid foundation. The token split is clean, the lint gates are strict, and every component has typed props, copy in COPY, and named states. The main risks are in the shared API surface that later phases will copy:

  • DataTable falls back to antd's own client-side pager when no pagination is passed.
  • The modal components push loading, error and double-submit handling onto every caller.
  • useConcurrencyQueue silently swallows job errors.
  • Route matching and document-title logic exist twice and already disagree.

No security issues were found. React 19 blocks javascript: hrefs, and external links use noopener noreferrer.

🔴 Critical 🟠 Major 🟡 Minor ⚪ Nit
0 5 14 2

🟠 Major

1. DataTable without pagination falls back to antd's default client-side pager — ui/data-table/data-table.tsx:56-66

Area: Errors/async → correctness (shared component)

Why:

  • pagination={pagination && {...}} passes undefined when the prop is omitted.
  • antd treats only false as "off". With undefined it renders its own pager, which pages client-side at 10 rows and ignores PAGE_SIZE_OPTIONS and the URL state from useListState.
  • The optional prop reads as "no pagination", so every non-paginated caller will silently cut its list at 10 rows behind a second, unstyled pager. The ui-kit "selectable", "long text" and "empty" cases already render without pagination.

Fix:

pagination={
  pagination
    ? { current: pagination.page, pageSize: pagination.pageSize, total: pagination.total, /* … */ }
    : false
}

2. The modal APIs leave async handling to every call site, and FormModal can be closed mid-submit — ui/confirm-modal/types.ts:30-32, ui/form-modal/types.ts:30-34, ui/form-modal/form-modal.tsx:45-56

Area: Errors/async → loading state, double submit; State → modals

Why:

  • onConfirm and onSubmit are typed () => void, and loading is a caller-owned boolean. So every phase-2+ call site has to write its own useState(loading) + operator(..., { setOperating }) + close-on-success sequence. Both demos (confirm-modal.demo.tsx:42-51, form-modal.demo.tsx:36-48) already hand-roll it.
  • That is the scattered-loading-state pattern the standard asks to solve once.
  • The two dialogs also disagree:
    • ConfirmModal locks Esc and mask-close while loading (keyboard={!loading}, mask={{ closable: !loading }}).
    • FormModal has no such guard. A user can close it while the request is in flight, and the success handler then runs against a closed or destroyed form.
  • FormModal is not tied to an antd Form, so Enter-to-submit and validation also have to be wired by hand each time.

Fix: Keep the presentational props. Add a small hook in src/ui/hooks (sketch under Suggested shared building blocks). Also guard FormModal the same way as ConfirmModal:

<Dialog keyboard={!loading} mask={{ closable: !loading }} onCancel={loading ? undefined : onCancel} … />

3. useConcurrencyQueue swallows job errors and keeps running after unmount — ui/hooks/use-concurrency-queue.ts:44-58

Area: Errors/async → no swallowing; effect cleanup

Why:

  • .catch(() => undefined) turns every job failure into a resolved Promise<void>. The caller cannot tell success from failure, and nothing reaches a toast.
  • The standard forbids exactly this kind of .catch(() => {}) in a shared hook.
  • The hook also has no unmount cleanup. Queued jobs keep starting after the owning page unmounts, and the demo's jobs call setState on an unmounted component (use-concurrency-queue.demo.tsx:39-44).
  • If this hook backs bulk "retest connection" later, failed retests will show as done.

Fix:

const enqueue = useCallback(<T,>(key: string, job: () => Promise<T>): Promise<T> => {
  const existing = pending.current.get(key) as Promise<T> | undefined;
  if (existing) return existing;
  const promise = new Promise<T>((resolve, reject) => {
    waiting.current.push(() => {
      job().then(resolve, reject).finally(() => { running.current -= 1; pending.current.delete(key); pump(); });
    });
  });
  pending.current.set(key, promise);
  pump();
  return promise;            // caller routes errors through operator / toUserMessage
}, [pump]);

useEffect(() => () => { waiting.current = []; }, []);   // drop jobs not yet started on unmount

4. Route matching is written twice and the two copies already disagree — ui/hooks/use-route-tab.ts:23-29 vs ui/sidebar-nav/utils.ts:43-55

Area: Reusability → duplicate logic

Why:

  • Both files implement "longest path prefix wins".
  • matchesPath in sidebar-nav/utils.ts handles a path that ends in /. matches in use-route-tab.ts does not. PATHS.ROOT() returns `${PATH_PREFIX}/`, so a tab or nav item pointing at root behaves differently in the two places.
  • Visibility filtering is also done twice: in useRouteTab and again in RouteTabs (route-tabs.tsx:31).

Fix: One pure util in ui/utils.ts, used by both:

export const isPathActive = (pathname: string, path: string) =>
  pathname === path || pathname.startsWith(path.endsWith('/') ? path : `${path}/`);

export const findLongestMatch = <T extends { path: string }>(items: T[], pathname: string) =>
  items.filter((i) => isPathActive(pathname, i.path)).sort((a, b) => b.path.length - a.path.length)[0];

5. Two document-title mechanisms, and PageHeader owns a global side effect — ui/hooks/use-document-title.ts:23-25, ui/page-header/page-header.tsx:29

Area: Reusability → single source of truth; Architecture → SRP

Why:

  • useDocumentTitle formats titles as Page · Brand. The 6 route files this PR touches still use <Helmet> with `${x} - ${BRAND_NAME}`. That is two separators and two owners of document.title once phase 2 pages start using PageHeader beside existing Helmet usage.
  • PageHeader calls useDocumentTitle(title) unconditionally. Any screen with more than one header races over document.title, and the last effect wins. Examples are a page header plus a drawer or sub-page header; the ui-kit page renders five PageHeaders.
  • Detail pages also cannot keep a different tab title from their visible heading.

Fix:

  • Make the title opt-in on PageHeader (documentTitle?: string | false), or have routes call useDocumentTitle themselves.
  • Export formatDocumentTitle and move the Helmet call sites onto it (or onto the hook) so there is one separator.

🟡 Minor

6. useListState returns a new object every render, and URL values are cast without checks — ui/hooks/use-list-state.ts:148, :55, :63

Area: Performance → hook return values; TypeScript → silencing casts

Why:

  • return { ...values, setPage, … } is a fresh object on every render. Callers naturally write useMemo(..., [list]), and the demo does exactly that (data-table.demo.tsx:77-82), so the memo recomputes every render.
  • sortBy as S and … as F accept any URL string as the typed union. A hand-edited ?sortBy=foo then flows to the API as a "valid" S.

Fix:

  • Wrap the return value in useMemo.
  • Accept an optional sortKeys: readonly S[] (and per-filter allowed values) and fall back to defaults when the URL value is not in the list.

7. DataTable shows no loading state on refetch — ui/data-table/data-table.tsx:43,51

Area: Errors/async → loading states

Why: loading={loading && dataSource.length === 0} hides the spinner after the first load. On a page or sort change, the URL-driven pager already shows the new page while the old rows stay visible, with no busy cue. That is briefly wrong data with no indication.

Fix: Pass loading={loading} (antd overlays the spinner on the existing rows), or at least set aria-busy={loading} and dim the rows.

8. Sort key lookup differs between the two DataTable helpers — ui/data-table/utils.ts:27-30 vs :40-46 (verify)

Area: TypeScript → API design

Why:

  • withSortOrder falls back to dataIndex when a column has no key.
  • toSortState reads sorter.columnKey, which antd fills from column.key only. I checked useSorter stateToInfo in antd 5.14; please confirm on 6.6.4.
  • So a sortable column defined with only dataIndex (allowed by the type) can never be sorted: clicking it emits onChange(undefined). The demo and tests always set key, so they don't catch it.

Fix: Require key on sortable columns in DataTableProps, e.g. columns: Array<TableColumnsType<T>[number] & ({ sorter?: undefined } | { sorter: true; key: string })>, or resolve the key the same way in both helpers.

9. Route tabs navigate through role="tab" controls instead of links — ui/route-tabs/route-tabs.tsx:33-43, ui/filter-tabs/filter-tabs.tsx:35-40

Area: Accessibility → semantics

Why:

  • RouteTabs renders antd Tabs/Segmented and calls navigate() from onChange. There is no href, so open-in-new-tab and middle-click don't work, and screen readers announce a tab widget rather than navigation.
  • The flat FilterTabs uses antd Tabs with no panels, so each role="tab" has an aria-controls that points at nothing.

Fix:

  • For RouteTabs, render labels as <Link to={path}> (antd Tabs items[].label) inside a <nav aria-label>, or use the Segmented variant with link labels.
  • For filters, prefer Segmented (role="radiogroup") for both variants.

10. Heading levels are fixed, so nested components break the outline — ui/empty-state/styled.ts:45, ui/section-card/styled.ts:51, ui/integration-card/styled.ts:33

Area: Accessibility → headings

Why:

  • EmptyState always renders an h2. Inside a SectionCard (also h2), as in section-card.demo.tsx:40-42 and in every DataTable empty state inside a card, the empty title becomes a sibling section heading.
  • IntegrationCard is h3 whatever its context.

Fix: Add headingLevel?: 2 | 3 | 4 (render with as={h${level}}), with a default chosen per size (PAGE → 2, SECTION → 3).

11. The modal title style is defined in three places — theme/antd-config.ts:141-145, ui/confirm-modal/styled.ts:27-31, ui/form-modal/styled.ts:24-28

Area: Reusability → single source of truth

Why:

  • The antd Modal component token already sets titleFontSize and titleLineHeight from TYPOGRAPHY.scale.h3.
  • Both dialogs then restyle .ant-modal-title with textStyle('h3') again.
  • Dialog and TitleRow are copy-pasted between the two folders (TitleRow differs only in align-items and gap).
  • A typography change has to be made in three places.

Fix: Drop the two .ant-modal-title overrides (add fontWeightStrong/titleColor to the Modal token if needed). Move the shared Dialog/TitleRow into one internal ui/modal-base/styled.ts.

12. Parallel state maps in ConnectionHealth — ui/connection-health/constants.ts:32-42

Area: Reusability → single source of truth

Why: STATE_TONE and STATE_LABEL are keyed by the same values and typed only as const. Adding a state means editing two maps, and nothing fails at compile time if one is missed.

Fix:

export const STATE_META = {
  [CONNECTION_HEALTH_STATE.ONLINE]: { tone: STATUS_TONE.SUCCESS, label: COPY.online },
  [CONNECTION_HEALTH_STATE.OFFLINE]: { tone: STATUS_TONE.ERROR, label: COPY.offline },
  [CONNECTION_HEALTH_STATE.UNKNOWN]: { tone: STATUS_TONE.NEUTRAL, label: COPY.unknown },
} as const satisfies Record<ConnectionHealthState, { tone: StatusTone; label: string }>;

13. PALETTE is untyped, and the light and dark modes already differ in shape — theme/palette.ts:30-114

Area: TypeScript → satisfies on token maps

Why:

  • Light colors overrides infoBg (:58). Dark has no infoBg, so dark keeps upstream primaryShades.darkest (#3C5088) while light gets the new #E6F7FF.
  • In light, infoBg and infoTintBg hold identical values.
  • Because PALETTE is a bare as const merged with { ...base, ...palette.colors }, a key missing in one mode is silent.

Fix: In palette.ts, declare a local shape type (it must stay import-free for e2e):

type ModePalette = { brand: {...}; colors: Record<'primary' | … | 'infoTintBg', string>; sidebar: {...} };
export const PALETTE = { light: {...}, dark: {...} } as const satisfies Record<'light' | 'dark', ModePalette>;

Then decide whether infoBg belongs in the fork block. If it does, add it to dark; if not, remove it from light.

14. Tokens borrowed for unrelated dimensions — ui/progress-banner/styled.ts:30,41

Area: Maintainability → styling tokens

Why:

  • The content banner's border uses theme.sidebar.divider, and its message flex-basis uses layout.sortSelectWidth. Restyling the sidebar or widening the sort select would silently change the banner.
  • The same habit appears in routes/ui-kit/styled.ts:106,113,131,138,150 (sidebarWidth and drawerWidth used as heights and grid minimums). That is dev-only there, but it is the pattern later phases will copy.

Fix: Use or add semantic tokens, e.g. colors.borderSubtle and layout.bannerMessageMinWidth.

15. CodeBlock: silent copy failure, a dead prop, and a second copy implementation — ui/code-block/code-block.tsx:35-44, ui/code-block/types.ts:25

Area: Errors/async → no swallowing; Maintainability → dead code; Reusability

Why:

  • If navigator.clipboard.writeText rejects (insecure origin, permission), the catch { return; } gives the user nothing. The button just doesn't change.
  • language is required but never read.
  • components/action/copy-text already copies text, using react-copy-to-clipboard plus a message.success toast. That leaves two copy behaviours and two feedback styles.

Fix:

const copy = () =>
  operator(() => navigator.clipboard.writeText(text), {
    hideToast: false,
    formatMessage: () => COPY.copied,
    formatReason: () => COPY.copyFailed,
  }).then(([ok]) => ok && flashCopied());
  • Drop language until there is a second language.
  • Plan to retire CopyText in favour of this component.

16. Two error-copy policies — ui/utils.ts:38-42 vs utils/operator.ts (formatReason ?? err.response?.data?.message)

Area: Errors/async → one error pipeline

Why:

  • toUserMessage deliberately never shows raw server text.
  • operator, the app's central mutation/toast helper, shows response.data.message verbatim by default.
  • As phase 2 adopts toUserMessage, some screens will hide pq: duplicate key… and others will toast it, depending on which helper the author picked.

Fix: Make toUserMessage the single policy. Have operator default to formatReason: (e) => toUserMessage(e, config.errorMap ?? {}), and have screens pass a map rather than a formatter.

17. Shared ui components read app branding and config directly — ui/app-shell/app-shell.tsx:32, ui/page-footer/page-footer.tsx:24, ui/brand-block/constants.ts:19-22

Area: Architecture → dependency inversion

Why:

  • AppShell hard-wires <PageFooter/>, which reads COPYRIGHT_HIDE from @/config/brand, so the shell cannot be rendered without that footer.
  • BrandBlock hard-codes the Arbisoft wordmark and repeats 'Arbisoft' twice as alt text.
  • Meanwhile config/brand.ts is described as "the only reader" of brand configuration, and BRAND_NAME is configurable. A deployment that sets DEVLAKE_BRAND_NAME gets a mismatched alt text and logo.

Fix:

  • Give AppShell a footer?: ReactNode slot and let the app pass <PageFooter/>.
  • Derive the BrandBlock alt text from BRAND_NAME, or move the logo asset choice into config/brand.ts.

18. SearchInput detects "clear" by sniffing the event type — ui/search-input/search-input.tsx:46-49 (verify)

Area: Maintainability → fragile coupling to library internals

Why: event.type === 'click' inside onChange relies on how rc-input fakes its clear event. A library update, or clearing with the keyboard, would stop resetting the search.

Fix: Use the input's dedicated clear callback (antd Input onClear, if available in 6.6.4 — verify) or allowClear plus a separate handler:

<Input allowClear={allowClear && { clearIcon: … }} onClear={() => onSearch('')} onChange={(e) => setDraft(e.target.value)} />

19. Small accessibility gaps in shell and status components

Area: Accessibility

Locations and fixes:

  • ui/progress-banner/progress-banner.tsx:27-28: role="region" is not live, so done/total changes are never announced. Its aria-label={message} also repeats the visible text. Put aria-live="polite" on the progress span and label the region with a short fixed name.
  • ui/code-block/code-block.tsx:48: <pre tabIndex={0} aria-label> has the generic role, so the label is not reliably announced. Add role="region".
  • ui/app-shell/app-shell.tsx:24-34: there is no "Skip to main content" link before the sidebar. Keyboard users have to tab through the whole nav on every page. Add a visually hidden skip link targeting <Main id>.

⚪ Nits

(More than 10 higher findings, so other nits were dropped as the skill directs.)

  • Component file convention (once for the whole PR): components are written as <name>/<name>.tsx plus index.ts, not <name>/index.tsx as the team standard says. This is consistent across src/ui, so record it as a convention decision rather than fixing each file.
  • Name collision: plugins/components/connection-list/constants.ts:29 exports MODAL_WIDTH = 820. @/ui exports a different MODAL_WIDTH ({ SM, MD, LG }). Rename the local one to CONNECTION_MODAL_WIDTH. The className="name" on the modal title span (connection-list/index.tsx:110) is no longer styled and can go.

Suggested shared building blocks

src/ui/hooks/use-confirm-flow.ts: one async wrapper for both dialogs, built on operator and toUserMessage (referenced from findings 2, 15 and 16):

export const useConfirmFlow = <A extends unknown[], T>(
  action: (...args: A) => Promise<T>,
  { errorMap = {}, successMessage, onSuccess }: { errorMap?: Record<string, string>; successMessage?: string; onSuccess?: (data: T) => void } = {},
) => {
  const [loading, setLoading] = useState(false);
  const run = useCallback(async (...args: A) => {
    if (loading) return;
    const [ok, data] = await operator(() => action(...args), {
      setOperating: setLoading,
      formatMessage: successMessage ? () => successMessage : undefined,
      formatReason: (e) => toUserMessage(e, errorMap),
    });
    if (ok) onSuccess?.(data);
  }, [loading, action, errorMap, successMessage, onSuccess]);
  return { run, loading };
};

// call site
const del = useConfirmFlow(() => deleteKey(id), { onSuccess: close });
<ConfirmModal open={open} loading={del.loading} onConfirm={del.run} onCancel={close} … />

ui/utils.ts findLongestMatch: see finding 4.

Pre-existing (outside this change)

  • routes/layout/layout.tsx:140,153: inline style={{ … color: '#fff' }} on the custom title and footer. The file is still on RESKIN_LINT_PENDING.
  • plugins/components/connection-list/index.tsx: typo hanldeHideForm, and destroyOnClose, which is deprecated in antd 6 (destroyOnHidden).
  • utils/operator.ts: console.error plus (err as any), and it returns [boolean, any?].
  • config/brand.ts:30: Boolean(env.DEVLAKE_COPYRIGHT_HIDE) treats the string "false" as true. This keeps the old truthiness semantics, but now it is centralised and could be fixed in one place.

Done well

  • Token architecture:
    • palette.ts is pure and import-free.
    • scales.ts holds sizes, antd-config.ts maps tokens onto antd, and DefaultTheme is fully typed.
    • The lint rules ban colour literals and style props on reskin-owned folders.
    • AA contrast is handled deliberately (errorActive for text, and the new *Text tokens).
  • Accessible component details:
    • ExternalLink and sidebar links include noopener noreferrer and a visually hidden "opens in a new tab" hint.
    • ConfirmModal focuses Cancel and locks itself while loading.
    • SectionCard and IntegrationCard are labelled by their own headings.
  • Dev-only route: the /ui-kit route is gated by import.meta.env.DEV and lazy-loaded, and it shows the empty, loading, error and overflow states for every component.

🤖 Generated with Claude Code

@ubaidkhalidarbisoft

Copy link
Copy Markdown

Review: approve with nits

Nothing breaks today, because no production route uses src/ui yet. The DataTable and SidebarNav issues below will show up once later phases use these components.

1. Medium: DataTable can't sort on the client when there's no sort prop. config-ui/src/ui/data-table/utils.ts:37, called from data-table.tsx:42

  • withSortOrder(columns, undefined) sets sortOrder: null on every sortable column, so antd treats sorting as controlled and stuck on "none". Header clicks do nothing.
  • Fix: return columns unchanged when sort is undefined.

2. Medium-low: DataTable still paginates when pagination is omitted. data-table.tsx:57

  • pagination={pagination && {...}} passes undefined, and antd reads that as "use the default pager" with 10 rows per page.
  • Fix: pagination={pagination ? {...} : false}.

3. Low: sort keys are handled differently in each direction. data-table/utils.ts:27 vs :42

  • withSortOrder falls back to dataIndex, but toSortState reads only sorter.columnKey. Clicking a sortable column that has no key clears the sort instead of setting it.
  • Fix: fall back to String(field) in toSortState, or require key on sortable columns.

4. Low: a nav group whose children are all hidden becomes a link. config-ui/src/ui/sidebar-nav/utils.ts:36

  • Once the children are filtered to [], the group renders as a leaf <Link to={item.path}>. For example, /advanced would go to a 404.
  • Fix: drop groups whose children were non-empty but all got filtered out.

5. Low: useListState can't clear a sort when there's a default sort. config-ui/src/ui/hooks/use-list-state.ts:64,82

  • antd's third header click ("cancel sort") snaps back to the default sort.
  • Fix: document this as intended, or set sortDirections: ['ascend', 'descend'] on sortable columns.

6. Low, licensing: the bundled font's licence file and LICENSE.

  • config-ui/src/fonts/OFL.txt has no ASF header and isn't in .licenserc.yaml paths-ignore, so asf-header-check.yml will fail on a PR into main.
  • The root LICENSE doesn't mention the bundled Figtree font or the OFL.

Nits

  • theme/palette.ts:58: light mode overrides infoBg to the same value as infoTintBg, and dark mode doesn't. That silently recolours copy-text and routes/pipeline/styled.ts.
  • ui/form-modal/form-modal.tsx:56: the modal can be closed with Esc, the mask or Cancel while it's saving. ConfirmModal blocks this.
  • ui/hooks/use-document-title.ts:30: PageHeader sets document.title with · while react-helmet uses -. Remove Helmet in phase 2 so the two don't fight.

🤖 Generated with Claude Code

@muzamilkm
muzamilkm deleted the feat/config-ui-reskin-p1-foundation branch October 16, 2026 09:57
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.

2 participants