Repository navigation
feat(config-ui): reskin phase 1, design foundation and src/ui - #87
Conversation
…ma placeholder names
1d8106e
into
arbisoft:feat/config-ui-reskin
ubaidkhalidarbisoft
left a comment
There was a problem hiding this comment.
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 => { |
There was a problem hiding this comment.
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 && { |
There was a problem hiding this comment.
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} |
There was a problem hiding this comment.
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 => { |
There was a problem hiding this comment.
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 } |
There was a problem hiding this comment.
Two small things:
- When
defaults.sortis set,setSort(undefined)removessortByfrom 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. sortByfrom the URL is cast toSwithout checking it, so any string can reach an API call. An optionalallowedSortslist in the defaults would close that.
| {title} | ||
| </TitleRow> | ||
| } | ||
| onCancel={onCancel} |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 = ' · '; |
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
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', |
There was a problem hiding this comment.
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.
React Review: PR #87 — reskin phase 1, design foundation and
|
| 🔴 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 && {...}}passesundefinedwhen the prop is omitted.- antd treats only
falseas "off". Withundefinedit renders its own pager, which pages client-side at 10 rows and ignoresPAGE_SIZE_OPTIONSand the URL state fromuseListState. - 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:
onConfirmandonSubmitare typed() => void, andloadingis a caller-owned boolean. So every phase-2+ call site has to write its ownuseState(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:
ConfirmModallocks Esc and mask-close while loading (keyboard={!loading},mask={{ closable: !loading }}).FormModalhas 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.
FormModalis not tied to an antdForm, 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 resolvedPromise<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
setStateon 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 unmount4. 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".
matchesPathinsidebar-nav/utils.tshandles a path that ends in/.matchesinuse-route-tab.tsdoes 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
useRouteTaband again inRouteTabs(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:
useDocumentTitleformats titles asPage · Brand. The 6 route files this PR touches still use<Helmet>with`${x} - ${BRAND_NAME}`. That is two separators and two owners ofdocument.titleonce phase 2 pages start usingPageHeaderbeside existing Helmet usage.PageHeadercallsuseDocumentTitle(title)unconditionally. Any screen with more than one header races overdocument.title, and the last effect wins. Examples are a page header plus a drawer or sub-page header; the ui-kit page renders fivePageHeaders.- 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 calluseDocumentTitlethemselves. - Export
formatDocumentTitleand 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 writeuseMemo(..., [list]), and the demo does exactly that (data-table.demo.tsx:77-82), so the memo recomputes every render.sortBy as Sand… as Faccept any URL string as the typed union. A hand-edited?sortBy=foothen 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:
withSortOrderfalls back todataIndexwhen a column has nokey.toSortStatereadssorter.columnKey, which antd fills fromcolumn.keyonly. I checkeduseSorterstateToInfoin 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 emitsonChange(undefined). The demo and tests always setkey, 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:
RouteTabsrenders antdTabs/Segmentedand callsnavigate()fromonChange. There is nohref, so open-in-new-tab and middle-click don't work, and screen readers announce a tab widget rather than navigation.- The flat
FilterTabsuses antdTabswith no panels, so eachrole="tab"has anaria-controlsthat points at nothing.
Fix:
- For
RouteTabs, render labels as<Link to={path}>(antdTabsitems[].label) inside a<nav aria-label>, or use theSegmentedvariant 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:
EmptyStatealways renders anh2. Inside aSectionCard(alsoh2), as insection-card.demo.tsx:40-42and in everyDataTableempty state inside a card, the empty title becomes a sibling section heading.IntegrationCardish3whatever 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
Modalcomponent token already setstitleFontSizeandtitleLineHeightfromTYPOGRAPHY.scale.h3. - Both dialogs then restyle
.ant-modal-titlewithtextStyle('h3')again. DialogandTitleRoware copy-pasted between the two folders (TitleRowdiffers only inalign-itemsandgap).- 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
colorsoverridesinfoBg(:58). Dark has noinfoBg, so dark keeps upstreamprimaryShades.darkest(#3C5088) while light gets the new#E6F7FF. - In light,
infoBgandinfoTintBghold identical values. - Because
PALETTEis a bareas constmerged 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 messageflex-basisuseslayout.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(sidebarWidthanddrawerWidthused 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.writeTextrejects (insecure origin, permission), thecatch { return; }gives the user nothing. The button just doesn't change. languageis required but never read.components/action/copy-textalready copies text, usingreact-copy-to-clipboardplus amessage.successtoast. 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
languageuntil there is a second language. - Plan to retire
CopyTextin 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:
toUserMessagedeliberately never shows raw server text.operator, the app's central mutation/toast helper, showsresponse.data.messageverbatim by default.- As phase 2 adopts
toUserMessage, some screens will hidepq: 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:
AppShellhard-wires<PageFooter/>, which readsCOPYRIGHT_HIDEfrom@/config/brand, so the shell cannot be rendered without that footer.BrandBlockhard-codes the Arbisoft wordmark and repeats'Arbisoft'twice as alt text.- Meanwhile
config/brand.tsis described as "the only reader" of brand configuration, andBRAND_NAMEis configurable. A deployment that setsDEVLAKE_BRAND_NAMEgets a mismatched alt text and logo.
Fix:
- Give
AppShellafooter?: ReactNodeslot and let the app pass<PageFooter/>. - Derive the BrandBlock alt text from
BRAND_NAME, or move the logo asset choice intoconfig/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, sodone/totalchanges are never announced. Itsaria-label={message}also repeats the visible text. Putaria-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. Addrole="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>.tsxplusindex.ts, not<name>/index.tsxas the team standard says. This is consistent acrosssrc/ui, so record it as a convention decision rather than fixing each file. - Name collision:
plugins/components/connection-list/constants.ts:29exportsMODAL_WIDTH = 820.@/uiexports a differentMODAL_WIDTH({ SM, MD, LG }). Rename the local one toCONNECTION_MODAL_WIDTH. TheclassName="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: inlinestyle={{ … color: '#fff' }}on the custom title and footer. The file is still onRESKIN_LINT_PENDING.plugins/components/connection-list/index.tsx: typohanldeHideForm, anddestroyOnClose, which is deprecated in antd 6 (destroyOnHidden).utils/operator.ts:console.errorplus(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.tsis pure and import-free.scales.tsholds sizes,antd-config.tsmaps tokens onto antd, andDefaultThemeis fully typed.- The lint rules ban colour literals and
styleprops on reskin-owned folders. - AA contrast is handled deliberately (
errorActivefor text, and the new*Texttokens).
- Accessible component details:
ExternalLinkand sidebar links includenoopener noreferrerand a visually hidden "opens in a new tab" hint.ConfirmModalfocuses Cancel and locks itself while loading.SectionCardandIntegrationCardare labelled by their own headings.
- Dev-only route: the
/ui-kitroute is gated byimport.meta.env.DEVand lazy-loaded, and it shows the empty, loading, error and overflow states for every component.
🤖 Generated with Claude Code
Review: approve with nitsNothing breaks today, because no production route uses 1. Medium:
2. Medium-low:
3. Low: sort keys are handled differently in each direction.
4. Low: a nav group whose children are all hidden becomes a link.
5. Low:
6. Low, licensing: the bundled font's licence file and
Nits
🤖 Generated with Claude Code |
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/uicomponent library, and a dev-only/ui-kitroute that shows every component./ui-kit.82f5e16d8.Theme and brand
src/theme/palette.tsholds the pure colour values for both modes. It has no imports, so e2e can import it.scales.tsholds space, radius, shadow, layout, z-index, motion and typography.antd-config.tsmaps everything onto antd, including the component tokens.tokens.tskeeps upstream's colour objects untouched and overrides them from one fork block.styled.d.tstypes the whole theme for styled-components.errorActive(#CF1322).#F5222Dis 4.08:1 on white, below AA, so it stays for fills, borders and icons only. That covers antdcolorErrorTextandStatusBadge.successText,warningText,infoText) are added because antd's defaults were 2.0–4.1:1.src/config/brand.tsis the only reader of the fourDEVLAKE_*brand variables.BRAND_NAMEdefault is now "Arbisoft DevLake", so the browser tab title changes.DEVLAKE_COLOR_CUSTOMnow also drives link, focus ring and icon colours, with hover and active shades fromgenerate().@ant-design/colorsis 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.@fontsource/figtree@5.3.0, with the OFL text insrc/fonts/OFL.txt.index.cssno longer has hex values.Canvas/CanvasText), so a dark OS gets no white flash.prefers-reduced-motionrule.src/uitypes.ts,constants.ts(COMMON_COPY,LIST_PARAMS,STATUS_TONE,SORT_ORDER) andutils.ts(toUserMessage,formatRelativeTime,formatDateTime).useListState(URL-backed),useRouteTab,useDocumentTitle,useInView,useSidebarCollapsed,useConcurrencyQueue.PluginIcon,StatusBadge,ExternalLink,IdentityCell,KeyValueList,CodeBlock,EmptyState(with 4 themed illustrations),MetricTile,PipelineProgress,SearchInput,FilterTabs,SortSelect,Toolbar.PageHeader,SectionCard,DataTable,ConfirmModal,FormModal,DetailDrawer,RouteTabs,ConnectionHealth,IntegrationCard,ProgressBanner.BrandBlock,AccountBlock,SidebarNav,PageFooter,AppShell.SidebarNavis antdMenu. It usesinlineCollapsedfor the 72 px rail with tooltips andtriggerSubMenuAction="click"for the rail flyout. It is styled through theMenucomponent tokens.styleprop, no colour literal, noany;COPY;/ui-kit/ui-kitis registered only whenimport.meta.env.DEVand loaded withlazy().grep -r ui-kit dist/finds nothing, so it never ships.Plugin icons
PluginIconswap:plugins/components/{connection-list,connection-select,plugin-name}now render plugin icons throughPluginIcon, so they take the brand tint.styled.ts, copy moved toCOPY, the empty interface is fixed, and the visible text is byte-identical.src/features/connections/components/name.tsx.src/featuresstays stable until phase 4.Regression proof
E2E, state B, on a stack rebuilt from this branch:
332af3ebb, run by the orchestratorui-kit.spec.tsagainstyarn start(twice)connections/catalog.spec.ts,--repeat-each=5ui-kit.spec.ts, which runs only whenE2E_UI_KIT_URLpoints at ayarn startserver.writes: [].ui-kit/ui-kit.spec.ts, with theUiKitPagepage object.shell/theme.spec.ts: it now also asserts that the computed body background equalsPALETTE[mode].colors.bgContainer, imported fromsrc/theme/palette.ts.connections/catalog.spec.ts:57failed 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:SearchInput,FilterTabs,DataTable,SidebarNav,RouteTabs,ConnectionHealth,IntegrationCard,ConfirmModal,FormModal.yarn build, andgrep -r ui-kit dist/finds nothing.yarn install --immutable.yarn e2e:census: OK.yarn quality:baseline: OK. The baseline went down by one: theno-empty-object-typewarning was fixed.lint:quality: 0 errors in every file created or substantively edited.find:duplicates: 119 clones / 2,083 lines, unchanged, with no clone in new code./ui-kitspec, as above./ui-kitchecked in Chromium at 1440 and 1024, light and dark, against the Figma nodes:yarn npm audit(advisory): not available, because the registry returns404 NOT_IMPLEMENTEDfor the audit endpoint.Notes for reviewers
9bc598045):82f5e16d8: theme tokens, brand config and Figtree.2a9f4aad1:src/uibase, hooks, leaf components and the/ui-kitroute.06980b719: composite and shell components, with their demos.f76692359: review fixes (rail icon contrast, placeholder fixture names).64aac596a: e2e.332af3ebb: shared component standards pass on the three touched plugin components.c0e130505(Node 24.19.0 in.tool-versions), and2423c667aand0ce0f113f(LINT_TOOLING_SETUP.mdcommand reference, plus a knip note).selectedBgis#D1CCFF, to match Figma;drawerWidthis 600, not 560, matching Figma's activity drawer;lg(800) is a guess;#685DA6(Figma#5B4FBF);layout.tsx: phase 2;connection.tsx: phase 4;tokens.tskeeps upstream's colour objects verbatim; all fork values sit in one block after them;src/plugins/components/*files changed, with styles moved to newstyled.tsfiles;vitest.config.tsgainssvgr();src/app/router.tsxgains the dev-only route.