Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
befe322
feat(issue-143): add Valibot schemas and fixtures for .comapeocat cat…
Jul 22, 2026
67d6958
feat(issue-143): add usePresets and useCategories data layer hooks
Jul 22, 2026
fce7453
feat(issue-143): add Categories route, nav icon, screen shell, and i18n
Jul 22, 2026
922f894
feat(issue-143): add category card grid with grouped layout
Jul 22, 2026
665c016
fix(issue-143): address review findings — TS errors, unused import, s…
Jul 22, 2026
51e3ccc
fix(issue-143): resolve field labels from useFields data, not preset …
Jul 22, 2026
330242a
feat(issue-143): add category detail pane and field viewer
Jul 22, 2026
576b89c
feat(issue-143): add .comapeocat import with Dexie persistence
Jul 22, 2026
cf107f5
fix(issue-143): restore usePresets backward compat, add useApiPresets
Jul 22, 2026
73017a8
fix(issue-143): fix ESLint set-state-in-effect in useCategorySets
Jul 22, 2026
312003e
fix(issue-143): prettier formatting + TS strict null fix
Jul 22, 2026
017b8be
fix(issue-143): prettier formatting on remaining files
Jul 22, 2026
8cf8a39
fix(issue-143): extract i18n messages + add pt/es translations
Jul 22, 2026
28b93c1
fix(issue-143): address claude-qwen review — error handling, cleanup,…
Jul 22, 2026
e51bbe1
fix(issue-143): add no-project empty state + fix FieldViewer type names
Jul 22, 2026
8888825
fix(issue-143): handleFileChange error handling + i18n coverage
Jul 22, 2026
3d47386
fix(issue-143): update FieldViewer test fixtures to camelCase type names
Jul 22, 2026
9101e14
fix(issue-143): use docRefSchema for preset fieldRefs/iconRef
Jul 22, 2026
dafaf1a
test(issue-143): add regression tests for fieldRefs/iconRef without url
Jul 22, 2026
7fd65a9
fix(issue-143): address claude-qwen review HIGH findings
Jul 22, 2026
31ba168
test(issue-143): add screen success path + real diacritic tests
Jul 22, 2026
e0291f3
test(issue-143): add MEDIUM coverage gaps — CategoryCard interactions…
Jul 22, 2026
e04a189
fix(issue-143): useApiPresets must use project remoteId (base32), not…
Jul 22, 2026
8d86068
fix(issue-143): guard projects loading/error states, assert remoteId …
Jul 22, 2026
fe8fb3f
fix(issue-143): handle 304 Not Modified in handleResponse + no-store …
Jul 22, 2026
abca306
fix(issue-143): remove unsafe 304→{data:[]} fallback, add no-store to…
Jul 23, 2026
4eb779b
fix(issue-143): make preset schema tolerant of server-omitted fields
Jul 23, 2026
24a02d7
fix(issue-143): pass archive server config to useApiPresets
Jul 23, 2026
8d32053
fix(issue-143): surface fields query errors in CategoriesEditor
Jul 23, 2026
b89a131
fix(issue-143): show no-server message when archive server unconfigured
Jul 23, 2026
c81a31b
test(e2e): add Categories Editor E2E tests with remote server data
Jul 23, 2026
4487802
fix(auth-store): auto-select archive server when only one configured
Jul 23, 2026
aacf5c3
fix(e2e): fix categories.e2e syntax for CI (TS 6.0 compat)
Jul 23, 2026
d4c2faf
fix(issue-143): show fields retry button when search returns no results
Jul 23, 2026
c2243f5
fix(ci): extract i18n messages and format e2e test
Jul 23, 2026
4d5cbc4
fix(issue-143): address review findings — stale-closure, auth guards,…
Jul 23, 2026
18c7c5c
fix(e2e): seed localStorage via addInitScript to prevent execution co…
Jul 23, 2026
fc4bc66
fix(e2e): seed IndexedDB on home page before navigating to /categories
Jul 23, 2026
d69fb7c
fix(e2e): use domcontentloaded for / to avoid networkidle timeout, us…
Jul 23, 2026
1f67497
fix(e2e): start IndexedDB seed in addInitScript so data is ready befo…
Jul 23, 2026
7a798bd
fix(e2e): seed IndexedDB after page load via page.evaluate, then reload
Jul 23, 2026
5588435
fix(e2e): navigate to / first to init Dexie, seed, then go to /catego…
Jul 23, 2026
c7c3bb8
fix(e2e): seed localStorage + IndexedDB via page.evaluate, no addInit…
Jul 23, 2026
bd25341
fix(e2e): addInitScript for localStorage, page.evaluate for IndexedDB
Jul 23, 2026
df9dca4
fix(e2e): seed all IndexedDB stores in addInitScript, then reload
Jul 23, 2026
a2c166d
fix(e2e): seed DB via page.evaluate + dynamic import hydrateServers
Jul 23, 2026
01388b3
fix(e2e): use @ alias for dynamic import of auth-store
Jul 23, 2026
5e10a29
feat(categories): render server SVG icons on category cards and detai…
Jul 24, 2026
3261c66
refactor(categories): reuse ObservationCategoryIcon instead of duplic…
Jul 24, 2026
914cb18
fix: hide letter fallback when icon loads, remove dead i18n key
Jul 24, 2026
6ef75e6
fix(categories): drop synthetic tag fallback and count only tags.cate…
Jul 24, 2026
fae7657
fix(categories): drop synthetic tag fallback and count only tags.cate…
Jul 24, 2026
4629a23
test(category-utils): add cases to restore 80% function coverage
Jul 24, 2026
55d233c
feat(categories): show only the latest category set (#148)
luandro Jul 25, 2026
f40ba1a
fix: regenerate bun.lock for postcss override
Jul 25, 2026
bdfbb74
fix: address Opus 5 review findings (color source, skeleton guard, re…
Jul 25, 2026
e66a444
fix: update color test for top-level preset field
Jul 25, 2026
0c354e7
fix: add isEnabled to useApiPresets mock in CategoriesEditor tests
Jul 26, 2026
0fcc2a9
fix(e2e): correct no-results assertion in search filters test
Jul 26, 2026
7c8d505
fix(categories): deduplicate preset versions from remote archive so e…
Jul 26, 2026
0809565
fix(categories): address Opus 5 review nits — i18n, loading flash, HT…
Jul 26, 2026
d404921
fix(a11y): render category icon as <span> not <div> inside <button> —…
Jul 26, 2026
dee74fa
fix(categories): TDD — center SVG icons + tolerate partial field resp…
Jul 26, 2026
2a70770
fix: accept real archive /field wire-contract (tagKey + camelCase types)
Jul 26, 2026
4abe943
Close field-fix review gaps (A–D) for issue #143 / PR #147
Jul 27, 2026
1ec41d0
fix: address Opus 5 readiness findings (icon sizing + import wiring)
Jul 27, 2026
d6ac72c
fix: use primary-hover token + 44px touch target on CategoriesEditor …
Jul 27, 2026
0152a5d
refactor: comment out Import Category Set UI (not functional yet)
Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,14 @@ jobs:
# GHSA-p63j-vcc4-9vmv: @vitest/browser Browser Mode file-access
# bypass. Fixed in 4.1.10 (we have 4.1.10) but bun's advisory DB
# has not been updated to recognise the fix version yet.
run: bun audit --audit-level=high --ignore=GHSA-gv7w-rqvm-qjhr --ignore=GHSA-vmh5-mc38-953g --ignore=GHSA-vxpw-j846-p89q --ignore=GHSA-hm92-r4w5-c3mj --ignore=GHSA-3jxr-9vmj-r5cp --ignore=GHSA-52cp-r559-cp3m --ignore=GHSA-395f-4hp3-45gv --ignore=GHSA-gcfj-64vw-6mp9 --ignore=GHSA-p63j-vcc4-9vmv
#
# GHSA-mh99-v99m-4gvg: brace-expansion DoS via unbounded expansion
# length causing OOM (transitive dev dep via eslint/glob).
# Cannot override to v5 — breaks minimatch's default import.
#
# GHSA-r28c-9q8g-f849: PostCSS source map path traversal
# (transitive dev dep via storybook). Resolved via override.
run: bun audit --audit-level=high --ignore=GHSA-gv7w-rqvm-qjhr --ignore=GHSA-vmh5-mc38-953g --ignore=GHSA-vxpw-j846-p89q --ignore=GHSA-hm92-r4w5-c3mj --ignore=GHSA-3jxr-9vmj-r5cp --ignore=GHSA-52cp-r559-cp3m --ignore=GHSA-395f-4hp3-45gv --ignore=GHSA-gcfj-64vw-6mp9 --ignore=GHSA-p63j-vcc4-9vmv --ignore=GHSA-mh99-v99m-4gvg

# -----------------------------------------------------------------------
# Preview deploy on pull_request
Expand Down
859 changes: 425 additions & 434 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"form-data": "^4.0.6",
"joi": "^17.13.4",
"js-yaml": "^3.15.0",
"postcss": "^8.5.23",
"sharp": "^0.35.3",
"shell-quote": "^1.10.0",
"ws": "^8.21.0"
Expand Down
Binary file modified screenshots/screenshot/archive-sidebar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot/create-project-dialog.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot/home-project-empty-cloud.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot/home-project-empty-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot/home-project-empty-sentinel.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot/home-skeleton.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot/language-selector-open.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions screenshots/screenshot/mobile-observation-detail.png.argos.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"url": "http://localhost:5173/data/observations/nonexistent-id",
"colorScheme": "light",
"mediaType": "screen",
"test": {
"id": "99fb1864c5a05941a39e-a72832ff12247d95f3c8",
"title": "mobile-observation-detail light",
"titlePath": [
"mobile-audit.screenshots.ts",
"Mobile audit screenshots",
"mobile-observation-detail light"
],
"retry": 0,
"retries": 0,
"repeat": 0,
"location": {
"file": "tests/e2e/mobile-audit.screenshots.ts",
"line": 146,
"column": 3
},
"annotations": []
},
"browser": { "name": "chromium", "version": "148.0.7778.96" },
"automationLibrary": { "version": "1.60.0", "name": "@playwright/test" },
"sdk": { "name": "@argos-ci/playwright", "version": "7.0.5" },
"viewport": { "width": 375, "height": 812 },
"transient": {}
}
Binary file added screenshots/screenshot/mobile-settings-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions screenshots/screenshot/mobile-settings-dark.png.argos.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"url": "http://localhost:5173/settings",
"colorScheme": "light",
"mediaType": "screen",
"test": {
"id": "99fb1864c5a05941a39e-dbf366030444c71ed5e8",
"title": "mobile-settings dark",
"titlePath": [
"mobile-audit.screenshots.ts",
"Mobile audit screenshots",
"mobile-settings dark"
],
"retry": 0,
"retries": 0,
"repeat": 0,
"location": {
"file": "tests/e2e/mobile-audit.screenshots.ts",
"line": 338,
"column": 3
},
"annotations": []
},
"browser": { "name": "chromium", "version": "148.0.7778.96" },
"automationLibrary": { "version": "1.60.0", "name": "@playwright/test" },
"sdk": { "name": "@argos-ci/playwright", "version": "7.0.5" },
"viewport": { "width": 375, "height": 812 },
"transient": {}
}
Binary file modified screenshots/screenshot/mobile-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot/settings-backup-restore.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot/settings-clear-data.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot/settings-cloud.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot/settings-invite-form.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot/settings-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot/settings-sentinel.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { AuthenticatedLayout } from '@/components/layout/authenticated-layout';
import { AlertDetailScreen } from '@/screens/AlertDetailScreen';
import { AlertsScreen } from '@/screens/AlertsScreen';
import { CategoriesEditorScreen } from '@/screens/CategoriesEditor';
import { CreateAlertScreen } from '@/screens/CreateAlertScreen';
import { DataScreen } from '@/screens/DataScreen';
import { HomeScreen } from '@/screens/Home/HomeScreen';
Expand Down Expand Up @@ -93,6 +94,18 @@ const settingsRoute = createRoute({
component: SettingsScreen,
});

const categoriesRoute = createRoute({
getParentRoute: () => _authenticatedRoute,
path: '/categories',
component: CategoriesEditorScreen,
});

const categoryDetailRoute = createRoute({
getParentRoute: () => _authenticatedRoute,
path: '/categories/$categoryId',
component: CategoriesEditorScreen,
});

const inviteRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/invite',
Expand All @@ -112,6 +125,8 @@ const routeTree = rootRoute.addChildren([
createAlertRoute,
alertDetailRoute,
settingsRoute,
categoriesRoute,
categoryDetailRoute,
]),
]);

Expand All @@ -136,6 +151,8 @@ export {
createAlertRoute,
alertDetailRoute,
settingsRoute,
categoriesRoute,
categoryDetailRoute,
};

// Export route tree for testing
Expand Down
21 changes: 21 additions & 0 deletions src/components/layout/authenticated-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const messages = defineMessages({
data: { id: 'data.title', defaultMessage: 'Data' },
alerts: { id: 'alerts.title', defaultMessage: 'Alerts' },
map: { id: 'map.title', defaultMessage: 'Map' },
categories: { id: 'categories.title', defaultMessage: 'Categories' },
settings: { id: 'settings.title', defaultMessage: 'Settings' },
appTitle: { id: 'app.title', defaultMessage: 'CoMapeo Cloud' },
});
Expand Down Expand Up @@ -112,6 +113,21 @@ function SettingsIcon(): ReactNode {
);
}

function CategoriesIcon(): ReactNode {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={20}
height={20}
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path d="M2 4a2 2 0 012-2h4a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2V4zm10 0a2 2 0 012-2h4a2 2 0 012 2v4a2 2 0 01-2 2h-4a2 2 0 01-2-2V4zM2 12a2 2 0 012-2h4a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4zm10 0a2 2 0 012-2h4a2 2 0 012 2v4a2 2 0 01-2 2h-4a2 2 0 01-2-2v-4z" />
</svg>
);
}

/** Normalize a URL for comparison by stripping trailing slashes. */
function normalizeUrl(url: string): string {
return url.trim().replace(/\/+$/, '');
Expand Down Expand Up @@ -250,6 +266,11 @@ function AuthenticatedLayoutInner() {
label: intl.formatMessage(messages.map),
icon: <MapIcon />,
},
{
path: '/categories',
label: intl.formatMessage(messages.categories),
icon: <CategoriesIcon />,
},
{
path: '/settings',
label: intl.formatMessage(messages.settings),
Expand Down
57 changes: 37 additions & 20 deletions src/components/shared/ObservationCategoryIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,35 +15,47 @@ interface ObservationCategoryIconProps {
ObservationCategory,
'id' | 'name' | 'color' | 'iconDocId' | 'iconUrl'
>;
size?: number; // circle diameter in pixels (default 32)
iconSize?: number; // image diameter in pixels (default size * 0.7)
className?: string;
}

export function ObservationCategoryIcon({
category,
className = 'h-8 w-8',
size = 32,
iconSize = size * 0.7,
className,
}: ObservationCategoryIconProps) {
const intl = useIntl();

const fallbackLetter = category.name.trim().slice(0, 1).toUpperCase() || '?';

const circleStyle = {
width: size,
height: size,
border: category.color
? `2px solid ${category.color}`
: '2px solid var(--color-primary, #1F6FFF)',
};

// When an icon image is available, render it on a white circle with a
// colored border so the icon remains legible regardless of the category
// color. The letter fallback is hidden behind the image via z-index.
// color. The letter is hidden (opacity-0) — it only shows in the no-icon
// fallback branch below.
if (category.iconUrl) {
return (
<div
className={`relative flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-white ${className}`}
<span
className={`relative flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-white ${className ?? ''}`}
data-testid="category-icon"
style={{
border: category.color
? `2px solid ${category.color}`
: '2px solid var(--color-primary, #1F6FFF)',
}}
style={circleStyle}
>
<span
data-testid="category-icon-fallback"
className="text-[0.65rem] font-semibold leading-none"
style={{ color: category.color ?? undefined }}
className="m-auto text-[0.65rem] font-semibold leading-none opacity-0"
style={{
color: category.color ?? undefined,
fontSize: size * 0.4,
}}
aria-hidden="true"
>
{fallbackLetter}
Expand All @@ -53,32 +65,37 @@ export function ObservationCategoryIcon({
alt={intl.formatMessage(messages.iconAlt, {
category: category.name,
})}
className="absolute inset-0 z-10 h-full w-full object-contain p-1"
className="m-auto absolute inset-0 z-10 object-contain p-1"
style={{ width: iconSize, height: iconSize }}
cache
/>
</div>
</span>
);
}

// No icon image: colored circle with white background + colored border +
// colored letter — matches the "white bg, color as border" spec from PR 65
// design review.
return (
<div
className={`relative flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-white ${className}`}
<span
className={`relative flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-white ${className ?? ''}`}
data-testid="category-icon-fallback-only"
style={{
border: category.color
? `2px solid ${category.color}`
: '2px solid var(--color-primary, #1F6FFF)',
...circleStyle,
color: category.color ?? undefined,
}}
aria-label={category.name}
role="img"
>
<span className="text-[0.65rem] font-semibold leading-none">
<span
className="font-semibold leading-none"
style={{
fontSize: size * 0.4,
color: category.color ?? undefined,
}}
>
{fallbackLetter}
</span>
</div>
</span>
);
}
2 changes: 1 addition & 1 deletion src/components/shared/ObservationsMap/ObservationsMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export function ObservationsMap({
<div className="rounded-full border-2 border-white bg-surface-card shadow-md">
<ObservationCategoryIcon
category={categoryByObservationId.get(o.localId)!}
className="h-7 w-7"
size={28}
/>
</div>
) : (
Expand Down
13 changes: 9 additions & 4 deletions src/components/shared/auth-img.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { CSSProperties } from 'react';

import { Skeleton } from '@/components/ui/skeleton';
import { useAuthenticatedImageUrl } from '@/hooks/useAuthenticatedImageUrl';

interface AuthImgProps {
src: string;
alt: string;
className?: string;
style?: CSSProperties;
/**
* When true, the image blob is cached in IndexedDB for instant display on
* subsequent loads. Use only for small, stable assets (e.g. category icons).
Expand All @@ -21,22 +24,24 @@ interface AuthImgProps {
* - Error: Invisible placeholder div (preserves layout)
* - Success: <img> with blob URL
*/
export function AuthImg({ src, alt, className, cache }: AuthImgProps) {
export function AuthImg({ src, alt, className, style, cache }: AuthImgProps) {
const { blobUrl, isLoading, error } = useAuthenticatedImageUrl(src, {
cache,
});

if (isLoading) {
return (
<div data-testid="auth-img-skeleton" className={className}>
<div data-testid="auth-img-skeleton" className={className} style={style}>
<Skeleton width="100%" height="100%" />
</div>
);
}

if (error || !blobUrl) {
return <div className={className} data-testid="auth-img-error" />;
return (
<div className={className} style={style} data-testid="auth-img-error" />
);
}

return <img src={blobUrl} alt={alt} className={className} />;
return <img src={blobUrl} alt={alt} className={className} style={style} />;
}
Loading
Loading