Skip to content

Commit 03e90e6

Browse files
committed
chore(ui-kit): enforce local boundary checks
1 parent 9b62a87 commit 03e90e6

61 files changed

Lines changed: 1660 additions & 600 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/pull_request_template.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
## Review checklist
77

88
- [ ] Shared UI was added to `src/app/components/primitives/` or `src/app/components/patterns/` when appropriate
9+
- [ ] If this adds shared UI, I can explain why it is not a primitive, pattern, or token when applicable
910
- [ ] `src/app/components/shared/` was only used for app-specific shared UI or compatibility shims
1011
- [ ] Storybook stories were added or updated for shared UI changes
1112
- [ ] `pnpm check:stories` passes
13+
- [ ] `pnpm check:ui-kit` passes
1214
- [ ] Documentation was updated if Storybook taxonomy, component ownership, or UI system guidance changed
1315
- [ ] I tested responsive behavior where the UI changed
1416
- [ ] I tested relevant theme states where the UI changed

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ jobs:
3434
- name: Validate Storybook standards
3535
run: pnpm check:stories
3636

37+
- name: Validate UI kit boundaries
38+
run: pnpm check:ui-kit
39+
3740
- name: Typecheck
3841
run: pnpm typecheck
3942

.husky/pre-commit

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
pnpm check:lockfile && pnpm check && pnpm check:stories && pnpm typecheck && pnpm test
1+
pnpm check:lockfile && pnpm check && pnpm check:stories && pnpm check:ui-kit && pnpm typecheck && pnpm test

design-system/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ shared UI layer backed by:
88

99
- authoring in `src/app/components/primitives/` and `src/app/components/patterns/`
1010
- curated exports in `src/app/components/system/`
11+
- canonical developer imports in `src/app/ui-kit/`
1112
- shared visual decisions in theme/token helpers
1213
- Storybook as the review and documentation surface
1314
- component layers: `primitives/`, `patterns/`, `system/`, `shared/`, `layout/`, `ui/`, `figma/`
@@ -29,7 +30,7 @@ Use these docs when you are:
2930
2. Patterns over duplication. If the same structure appears across cards or dialogs, extract it.
3031
3. `system/` is a curated export layer, not the authoring location for new components.
3132
4. Performance matters. Visual richness cannot make the app unusable on wall panels or tablets.
32-
5. Storybook is the workshop. Stable shared UI should be reviewed there, not only in app pages.
33+
5. Storybook is the workshop and the internal UI-kit entrypoint. Stable shared UI should be reviewed there, not only in app pages.
3334
6. Unit tests cover logic seams. Shared utilities, store behavior, and controller logic should be
3435
verified in Vitest rather than only through visual review.
3536

design-system/STORYBOOK_FOUNDATION.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Navet should grow an internal component workshop before it grows a package works
66

77
- Keep Navet in a single repo for now.
88
- Build on an internal design-system layer under `src/app/components/system/`.
9+
- Treat Storybook as the official developer-facing UI-kit surface.
910
- Run Storybook in this repo instead of splitting into a package workspace.
1011
- Delay a monorepo or package split until there is more than one real consumer.
1112

@@ -57,10 +58,13 @@ The code foundation now groups stable exports into three buckets:
5758
- composed UI sections such as field wrappers, empty-state layouts, message bars, and preview cards
5859
- `src/app/components/system/`
5960
- curated public export surface for Storybook navigation and cross-app discovery
61+
- `src/app/ui-kit/`
62+
- canonical developer import surface for Navet UI
6063
- `src/app/components/system/tokens/`
6164
- theme surface helpers, accent shell treatments, color helpers, and style calculators
6265

6366
Author shared UI in `primitives/` or `patterns/` first. Re-export stable pieces through `system/`.
67+
For new usage and Storybook docs, prefer `@/app/ui-kit/*`.
6468

6569
## Story Hierarchy
6670

@@ -102,6 +106,7 @@ Storybook sorting is controlled centrally in `.storybook/preview.tsx`. Avoid inv
102106
- Every story should render in all four themes where possible.
103107
- Every interactive component should show idle, hover, active, disabled, and selected states.
104108
- Every pattern should have mobile and desktop variants.
109+
- Every Storybook docs example should use `@/app/ui-kit/*` imports when a stable shared export exists.
105110
- Prefer real Navet copy and spacing tokens over synthetic examples.
106111
- Keep feature-specific data loaders and Home Assistant wiring out of stories.
107112
- Co-locate the story with the component or feature it documents.
@@ -116,4 +121,4 @@ Only move to a package workspace if one of these becomes true:
116121
- The UI system needs independent release/versioning
117122
- Build and ownership boundaries become painful in one package
118123

119-
Until then, Storybook plus `src/app/components/system/` is the simpler path.
124+
Until then, Storybook plus `src/app/ui-kit/` is the simpler path.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
"format": "biome format --write ./src",
1818
"check": "biome check ./src",
1919
"check:stories": "node scripts/check-storybook-standards.mjs",
20+
"check:ui-kit": "node scripts/check-ui-kit-boundaries.mjs",
2021
"check:lockfile": "node scripts/check-pnpm-lock-sync.mjs",
2122
"check:fix": "biome check --apply ./src",
23+
"report:ui-kit": "node scripts/check-ui-kit-usage.mjs",
2224
"test": "vitest --config vitest.unit.config.ts --project unit",
2325
"test:coverage": "vitest --config vitest.unit.config.ts --project unit --coverage",
2426
"test:storybook": "vitest --project \"storybook:$PWD/.storybook\"",
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
#!/usr/bin/env node
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
5+
const ROOT = process.cwd();
6+
7+
const SHARED_DIRS = [
8+
'src/app/components/primitives',
9+
'src/app/components/patterns',
10+
'src/app/components/shared',
11+
'src/app/components/system',
12+
'src/app/ui-kit',
13+
];
14+
15+
const PUBLIC_EXPORT_DIRS = ['src/app/components/system', 'src/app/ui-kit'];
16+
17+
const LEGACY_MODAL_ALLOWLIST = new Set([
18+
'src/app/features/security/components/camera-card/camera-settings-dialog.tsx',
19+
'src/app/features/security/components/cover-card/view.tsx',
20+
'src/app/features/climate/components/hvac-settings-dialog/index.tsx',
21+
'src/app/features/weather/components/weather-card/weather-settings-dialog.tsx',
22+
'src/app/features/lighting/components/light-card/light-settings-dialog.tsx',
23+
'src/app/features/lighting/components/switch-settings-dialog.tsx',
24+
]);
25+
26+
function walk(dir) {
27+
const entries = fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true });
28+
const files = [];
29+
30+
for (const entry of entries) {
31+
const relativePath = path.join(dir, entry.name);
32+
33+
if (entry.isDirectory()) {
34+
files.push(...walk(relativePath));
35+
continue;
36+
}
37+
38+
if (!entry.isFile()) {
39+
continue;
40+
}
41+
42+
if (!/\.(ts|tsx|js|jsx|mjs)$/.test(entry.name) || entry.name.includes('.stories.')) {
43+
continue;
44+
}
45+
46+
files.push(relativePath);
47+
}
48+
49+
return files;
50+
}
51+
52+
const violations = [];
53+
54+
for (const dir of SHARED_DIRS) {
55+
for (const relativePath of walk(dir)) {
56+
const source = fs.readFileSync(path.join(ROOT, relativePath), 'utf8');
57+
58+
if (source.includes(`@/app/features/`)) {
59+
violations.push(`${relativePath}: shared UI layers must not import from feature modules`);
60+
}
61+
}
62+
}
63+
64+
for (const dir of PUBLIC_EXPORT_DIRS) {
65+
for (const relativePath of walk(dir)) {
66+
const source = fs.readFileSync(path.join(ROOT, relativePath), 'utf8');
67+
68+
if (/export\s+.*from\s+['"]@\/app\/features\//.test(source)) {
69+
violations.push(`${relativePath}: public UI-kit surfaces must not re-export feature modules`);
70+
}
71+
}
72+
}
73+
74+
for (const relativePath of [...walk('src/app/components/layout'), ...walk('src/app/features')]) {
75+
const source = fs.readFileSync(path.join(ROOT, relativePath), 'utf8');
76+
77+
const hasLegacyModalRecipe =
78+
/(fixed (left-1\/2 top-1\/2|top-1\/2 left-1\/2) z-50 .*shadow-2xl backdrop-blur-xl)/.test(
79+
source
80+
) || /fixed inset-x-0 bottom-0 z-50 .*rounded-\[30px\].*shadow-2xl/.test(source);
81+
82+
if (hasLegacyModalRecipe && !LEGACY_MODAL_ALLOWLIST.has(relativePath)) {
83+
violations.push(
84+
`${relativePath}: use shared ModalSurface or SheetSurface instead of reauthoring shell recipes`
85+
);
86+
}
87+
}
88+
89+
if (violations.length > 0) {
90+
console.error('\nUI kit boundary check failed:\n');
91+
for (const violation of violations) {
92+
console.error(`- ${violation}`);
93+
}
94+
process.exit(1);
95+
}
96+
97+
console.log('UI kit boundary check passed.');

scripts/check-ui-kit-usage.mjs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/usr/bin/env node
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
5+
const ROOT = process.cwd();
6+
const APP_DIR = path.join(ROOT, 'src/app');
7+
8+
function walk(dir) {
9+
const entries = fs.readdirSync(dir, { withFileTypes: true });
10+
const files = [];
11+
12+
for (const entry of entries) {
13+
const fullPath = path.join(dir, entry.name);
14+
15+
if (entry.isDirectory()) {
16+
files.push(...walk(fullPath));
17+
continue;
18+
}
19+
20+
if (!entry.isFile() || !/\.(ts|tsx)$/.test(entry.name) || entry.name.includes('.stories.')) {
21+
continue;
22+
}
23+
24+
files.push(fullPath);
25+
}
26+
27+
return files;
28+
}
29+
30+
const metrics = [];
31+
const featureLeakFiles = [];
32+
33+
for (const fullPath of walk(APP_DIR)) {
34+
const relativePath = path.relative(ROOT, fullPath);
35+
const source = fs.readFileSync(fullPath, 'utf8');
36+
const themeBranches = (source.match(/theme === '(light|glass|black|dark)'/g) ?? []).length;
37+
const inlineStyles = (source.match(/style=\{\{/g) ?? []).length;
38+
const arbitrarySurfaces =
39+
(source.match(/rounded-\[[^\]]+\]/g) ?? []).length +
40+
(source.match(/shadow-\[[^\]]+\]/g) ?? []).length +
41+
(source.match(/bg-\[[^\]]+\]/g) ?? []).length +
42+
(source.match(/border-\[[^\]]+\]/g) ?? []).length;
43+
44+
metrics.push({
45+
relativePath,
46+
themeBranches,
47+
inlineStyles,
48+
arbitrarySurfaces,
49+
});
50+
51+
if (
52+
/src\/app\/components\/(primitives|patterns|shared|system)\//.test(relativePath) &&
53+
source.includes(`@/app/features/`)
54+
) {
55+
featureLeakFiles.push(relativePath);
56+
}
57+
}
58+
59+
function printTop(title, key) {
60+
console.log(`\n${title}`);
61+
metrics
62+
.filter((entry) => entry[key] > 0)
63+
.sort((a, b) => b[key] - a[key])
64+
.slice(0, 15)
65+
.forEach((entry) => {
66+
console.log(`- ${entry[key].toString().padStart(3, ' ')} ${entry.relativePath}`);
67+
});
68+
}
69+
70+
printTop('Top files by theme branching', 'themeBranches');
71+
printTop('Top files by inline style usage', 'inlineStyles');
72+
printTop('Top files by arbitrary surface classes', 'arbitrarySurfaces');
73+
74+
console.log('\nShared-layer feature imports');
75+
if (featureLeakFiles.length === 0) {
76+
console.log('- none');
77+
} else {
78+
featureLeakFiles.sort().forEach((file) => console.log(`- ${file}`));
79+
}

src/app/components/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ This folder has three distinct roles:
1818
Do not treat `system/` as the source of truth for where new shared components are authored.
1919
`system/tokens` is the shared home for first-layer foundations such as spacing, radii, icon sizing, and focus behavior.
2020

21+
- `ui-kit/`
22+
Canonical import surface for Navet developers.
23+
Prefer `@/app/ui-kit/primitives`, `@/app/ui-kit/patterns`, and `@/app/ui-kit/tokens`
24+
in new docs, examples, and shared UI consumption. `system/` remains transitional.
25+
2126
- `shared/`
2227
Existing app-specific shared components that do not belong in the primitive/pattern system.
2328
Temporary compatibility shims may appear here during migrations, but they should not be long-lived.
@@ -31,6 +36,7 @@ Guidelines:
3136

3237
- Create new shared UI in `primitives/` or `patterns/` first.
3338
- Re-export stable shared pieces through `system/` once they are ready for broader use.
39+
- Prefer exposing and consuming stable shared UI through `ui-kit/`.
3440
- Before adding or widening primitives, align them to the existing foundation tokens in `src/app/components/system/tokens/`.
3541
- Treat any temporary compatibility shim in `shared/` as a migration path, not as the preferred import target.
3642
- Keep business logic and feature-specific state out of primitives.

src/app/components/layout/header-actions.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,14 @@ export function HeaderMobileActions({
5555
hoverBg={hoverBg}
5656
isNotificationOpen={isNotificationOpen}
5757
mobileNotificationButtonRef={mobileNotificationButtonRef}
58+
renderPanel
5859
setIsNotificationOpen={setIsNotificationOpen}
5960
textSecondary={textSecondary}
6061
unreadCount={unreadCount}
6162
mobile
6263
/>
6364

64-
<UserDropdown avatarUrl={avatarUrl} />
65+
<UserDropdown avatarUrl={avatarUrl} variant="mobile" />
6566
</div>
6667
);
6768
}
@@ -73,10 +74,13 @@ export function HeaderDesktopActions({
7374
hoverBg,
7475
isNotificationOpen,
7576
mobileNotificationButtonRef,
77+
renderPanel = true,
7678
setIsNotificationOpen,
7779
textSecondary,
7880
unreadCount,
79-
}: Omit<HeaderActionsProps, 'isMobileSearchOpen' | 'onToggleMobileSearch' | 'searchAriaLabel'>) {
81+
}: Omit<HeaderActionsProps, 'isMobileSearchOpen' | 'onToggleMobileSearch' | 'searchAriaLabel'> & {
82+
renderPanel?: boolean;
83+
}) {
8084
return (
8185
<>
8286
<HeaderNotificationButton
@@ -85,6 +89,7 @@ export function HeaderDesktopActions({
8589
hoverBg={hoverBg}
8690
isNotificationOpen={isNotificationOpen}
8791
mobileNotificationButtonRef={mobileNotificationButtonRef}
92+
renderPanel={renderPanel}
8893
setIsNotificationOpen={setIsNotificationOpen}
8994
textSecondary={textSecondary}
9095
unreadCount={unreadCount}
@@ -94,13 +99,14 @@ export function HeaderDesktopActions({
9499
);
95100
}
96101

97-
function HeaderNotificationButton({
102+
export function HeaderNotificationButton({
98103
activeColorValue,
99104
desktopNotificationButtonRef,
100105
hoverBg,
101106
isNotificationOpen,
102107
mobile = false,
103108
mobileNotificationButtonRef,
109+
renderPanel = false,
104110
setIsNotificationOpen,
105111
textSecondary,
106112
unreadCount,
@@ -111,6 +117,7 @@ function HeaderNotificationButton({
111117
isNotificationOpen: boolean;
112118
mobile?: boolean;
113119
mobileNotificationButtonRef: RefObject<HTMLButtonElement | null>;
120+
renderPanel?: boolean;
114121
setIsNotificationOpen: (open: boolean) => void;
115122
textSecondary: string;
116123
unreadCount: number;
@@ -140,7 +147,7 @@ function HeaderNotificationButton({
140147
) : null}
141148
</button>
142149

143-
{!mobile ? (
150+
{renderPanel ? (
144151
<NotificationPanel
145152
isOpen={isNotificationOpen}
146153
onClose={() => setIsNotificationOpen(false)}

0 commit comments

Comments
 (0)