diff --git a/.gitignore b/.gitignore index dd8cf3c..dc73968 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,74 @@ yarn-error.log* *.pem .llm/index.md apps/dashboard/aura-adminsdk-private-key.json +apps/core-old/index.html +apps/core-old/package.json +apps/core-old/README.md +apps/core-old/tsconfig.json +apps/core-old/tsconfig.tsbuildinfo +apps/core-old/vite.config.ts +apps/core-old/src/App.tsx +apps/core-old/src/main.ts +apps/core-old/src/vite-env.d.ts +apps/core-old/src/features/auth-backup/queries.ts +apps/core-old/src/features/brightid/queries.ts +apps/core-old/src/features/brightid/recovery/channelService.ts +apps/core-old/src/features/brightid/recovery/constants.ts +apps/core-old/src/features/brightid/recovery/cryptoHelper.ts +apps/core-old/src/features/brightid/recovery/encoding.ts +apps/core-old/src/features/brightid/recovery/recovery.ts +apps/core-old/src/features/brightid/recovery/thunks.ts +apps/core-old/src/features/connections/queries.ts +apps/core-old/src/features/evaluations/composables.ts +apps/core-old/src/features/evaluations/selectors.ts +apps/core-old/src/features/evaluations/types.ts +apps/core-old/src/features/subject-profile/queries.ts +apps/core-old/src/features/subject-profile/ui/connections-list.tsx +apps/core-old/src/features/subject-profile/ui/evaluations-list.tsx +apps/core-old/src/features/subject-profile/ui/outbound-activities.tsx +apps/core-old/src/features/subject-profile/ui/profile-header.tsx +apps/core-old/src/features/subject-profile/ui/profile-overview.tsx +apps/core-old/src/features/subjects-list/composables.ts +apps/core-old/src/features/subjects-list/ui/subject-card.tsx +apps/core-old/src/i18n/index.ts +apps/core-old/src/i18n/locales/en/translation.json +apps/core-old/src/lib/queryClient.ts +apps/core-old/src/lib/queryPersister.ts +apps/core-old/src/router/index.ts +apps/core-old/src/routes/contact-info.tsx +apps/core-old/src/routes/dashboard.tsx +apps/core-old/src/routes/domain-overview.tsx +apps/core-old/src/routes/home.tsx +apps/core-old/src/routes/landing.tsx +apps/core-old/src/routes/notifications.tsx +apps/core-old/src/routes/onboarding-tour.tsx +apps/core-old/src/routes/onboarding.tsx +apps/core-old/src/routes/role-management.tsx +apps/core-old/src/routes/settings.tsx +apps/core-old/src/routes/subject/\[id].tsx +apps/core-old/src/shared/api/index.ts +apps/core-old/src/shared/composables/useDecryptedBackup.ts +apps/core-old/src/shared/lib/contacts.ts +apps/core-old/src/shared/lib/crypto.ts +apps/core-old/src/shared/lib/env.ts +apps/core-old/src/shared/lib/levels.ts +apps/core-old/src/shared/lib/number.ts +apps/core-old/src/shared/lib/score.ts +apps/core-old/src/shared/lib/text.ts +apps/core-old/src/shared/lib/time.ts +apps/core-old/src/shared/lib/urls.ts +apps/core-old/src/shared/types/aura-ui-jsx.d.ts +apps/core-old/src/shared/types/aura.ts +apps/core-old/src/shared/types/dashboard.ts +apps/core-old/src/shared/types/index.ts +apps/core-old/src/shared/types/requirement.ts +apps/core-old/src/shared/ui/app-shell.tsx +apps/core-old/src/stores/auth.ts +apps/core-old/src/stores/index.ts +apps/core-old/src/stores/keypair.ts +apps/core-old/src/stores/operations.ts +apps/core-old/src/stores/recovery.ts +apps/core-old/src/stores/settings.ts +apps/core-old/src/stores/user.ts +apps/core-old/src/styles/index.css +.aider* diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 9bb1031..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,565 +0,0 @@ -> **LLMs:** Read `.llm/index.md` first for a compact workspace overview (structure, stacks, commands, memory, skills). Return here only when you need specific UI component APIs. - -# Aura UI — Component Reference - -This monorepo ships a Lit-based design system at `packages/ui` (`@aura/ui`). All apps in `./apps` consume it. Use this document when building any UI inside the apps. - -## Setup - -### Lit apps (interface, dashboard, players, docs) - -```ts -// Import the components you need — side-effect imports register the custom elements -import "@aura/ui" // registers all elements - -// Or import selectively -import "@aura/ui/src/components/button" -import "@aura/ui/src/components/card" -``` - -Always wrap the root of your app (or ``) with `` once: - -```html - - - -``` - -### React / Next.js apps (web) - -React wrappers are auto-generated at build time into `packages/ui/src/react-wrappers/`. Import from there: - -```tsx -import { AButton, ACard, AInput } from "@aura/ui/src/react-wrappers" -``` - ---- - -## Theme Provider - -`` injects all CSS custom properties used by every component. **Must be an ancestor of all UI components.** - -```html - -
...
-
-``` - -### CSS custom properties (available everywhere inside the provider) - -| Token | Use | -|---|---| -| `--background` | Page background | -| `--foreground` | Default text color | -| `--primary` / `--primary-foreground` | Brand color pair | -| `--secondary` / `--secondary-foreground` | Secondary color pair | -| `--muted` / `--muted-foreground` | Subdued backgrounds / text | -| `--accent` / `--accent-foreground` | Accent teal pair | -| `--destructive` / `--destructive-foreground` | Error/delete color pair | -| `--card` / `--card-foreground` | Card surface pair | -| `--border` | Borders and dividers | -| `--radius` | Base border radius (`0.75rem`) | -| `--aura-success` | Green success color | -| `--aura-warning` | Amber warning color | -| `--aura-info` | Blue info color | - ---- - -## Components - -### `` - -```html -Click me -Delete -Disabled -``` - -| Prop | Type | Default | Values | -|---|---|---|---| -| `variant` | string | `"default"` | `"default"` `"secondary"` `"ghost"` | -| `color` | string | `"primary"` | `"primary"` `"secondary"` `"success"` `"warning"` `"destructive"` | -| `size` | string | `"md"` | `"sm"` `"md"` `"lg"` | -| `disabled` | boolean | `false` | — | - -No events — wraps a ``, so nest a native ` +
+

Reading the impact chart

+ + Each bar is one evaluator; its height is that evaluation's share of + the subject's total impact. Bars below the line are negative + evaluations. Darker shades mean higher confidence. + + + + + + + Learn more + + +
+ + ) +} diff --git a/apps/core/src/components/charts/colors.ts b/apps/core/src/components/charts/colors.ts new file mode 100644 index 0000000..9c154d0 --- /dev/null +++ b/apps/core/src/components/charts/colors.ts @@ -0,0 +1,58 @@ +/** + * Bar palettes from the old app (`constants/chart.ts`), keyed by the signed + * confidence (-4…4): green/red shades for other evaluators, purple for the + * logged-in user's own bar, orange for the focused subject's bar. + */ +type ColorMap = Record + +export const valueColorMap: ColorMap = { + "-4": "#924848", + "-3": "#DA6A6A", + "-2": "#EE9D9D", + "-1": "#F5BFBF", + "1": "#D5ECDA", + "2": "#B4E6C0", + "3": "#72BF83", + "4": "#5B9969", +} + +export const userRatingColorMap: ColorMap = { + "-4": "#D9C7F9", + "-3": "#C2A8F3", + "-2": "#AC89ED", + "-1": "#956AE6", + "1": "#8341DE", + "2": "#6C34B3", + "3": "#572988", + "4": "#451F6D", +} + +export const subjectRatingColorMap: ColorMap = { + "-4": "#FAD7A0", + "-3": "#F8C471", + "-2": "#F5B041", + "-1": "#F39C12", + "1": "#E67E22", + "2": "#CA6A1A", + "3": "#AF5714", + "4": "#91450F", +} + +const clampSigned = (value: number) => + Math.max(-4, Math.min(4, Math.round(value))) || 1 + +/** Color for one evaluation bar (old `getBarChartColor`). */ +export function barColor( + signedConfidence: number, + evaluator: string, + authBrightId?: string, + focusedSubjectId?: string, +): string { + const map = + evaluator === authBrightId + ? userRatingColorMap + : evaluator === focusedSubjectId + ? subjectRatingColorMap + : valueColorMap + return map[String(clampSigned(signedConfidence))] +} diff --git a/apps/core/src/components/charts/evaluations-chart.tsx b/apps/core/src/components/charts/evaluations-chart.tsx new file mode 100644 index 0000000..1a3ef67 --- /dev/null +++ b/apps/core/src/components/charts/evaluations-chart.tsx @@ -0,0 +1,301 @@ +import { createEffect, createMemo, createSignal, For, Show } from "solid-js" +import { barColor } from "@/components/charts/colors" +import ZoomControls from "@/components/charts/zoom-controls" +import Avatar from "@/components/home/avatar" +import { useNameResolver } from "@/hooks/use-backup" +import { compactFormat } from "@/shared/lib/number" +import { formatDuration } from "@/shared/lib/time" +import { authStore } from "@/store/auth" +import { confidenceLabel } from "@aura/domain/labels" +import type { AuraImpactRaw } from "@aura/domain/types/aura" + +// Old chart showed evaluator pictures for up to 20 bars, 16–40px by count. +const MAX_AVATARS = 20 + +const avatarSize = (count: number) => + Math.round(40 - ((40 - 16) / (MAX_AVATARS - 1)) * (count - 1)) + +interface Bar { + id: string + name: string + /** Share of the subject's total absolute impact, signed (-100..100). */ + percent: number + color: string + /** Raw values carried for the hover tooltip (old recharts tooltip parity). */ + score: number | null + confidence: number + modified: number +} + +/** + * Evaluation-impacts chart (plan T2.5) — the old recharts chart rebuilt as + * plain CSS bars: one bar per evaluator, height = share of the subject's + * total impact, negatives below the baseline. Bars use the old confidence + * palettes (purple = you, orange = the focused subject) and carry the + * evaluator's picture underneath when the visible window is small enough. + * Click opens the evaluator; hover shows the old tooltip (score, impact, + * confidence, age). + * + * The strip never scrolls — bars flex to fill the width and shrink as small as + * needed, capped at a max width so a handful of bars don't sprawl. To inspect + * a crowded chart, the old drag-zoom is replaced by explicit zoom/pan controls + * that slide a window over the bars (see {@link ZoomControls}). + */ +export default function EvaluationsChart(props: { + impacts: () => AuraImpactRaw[] | null + onBarClick?: (evaluatorId: string) => void + /** Highlighted subject (orange palette), e.g. the profile being viewed. */ + focusedSubjectId?: () => string + /** Show a skeleton instead of the empty state while the data loads. */ + loading?: () => boolean +}) { + const nameOf = useNameResolver() + + const allBars = createMemo(() => { + const impacts = (props.impacts() ?? []).filter((i) => i.impact !== 0) + const total = impacts.reduce((sum, i) => sum + Math.abs(i.impact), 0) + if (!total) return [] + return [...impacts] + .sort((a, b) => a.impact - b.impact) + .map((i) => ({ + id: i.evaluator, + name: nameOf(i.evaluator), + percent: (i.impact / total) * 100, + color: barColor( + i.confidence * Math.sign(i.impact), + i.evaluator, + authStore.user?.brightId, + props.focusedSubjectId?.(), + ), + score: i.score, + confidence: i.confidence * Math.sign(i.impact), + modified: i.modified, + })) + }) + + // Visible window [start, end] into allBars(). Reset to the full range + // whenever the data changes. + const [start, setStart] = createSignal(0) + const [end, setEnd] = createSignal(0) + createEffect(() => { + setStart(0) + setEnd(Math.max(0, allBars().length - 1)) + }) + + const count = () => allBars().length + const bars = createMemo(() => allBars().slice(start(), end() + 1)) + + // Heights are normalized to the tallest bar *in the window*, so zooming into + // a flat region still spreads the bars across the full plot height. + const windowMaxAbs = createMemo(() => + Math.max(1, ...bars().map((b) => Math.abs(b.percent))), + ) + const heightOf = (percent: number) => + Math.max(8, Math.round((Math.abs(percent) / windowMaxAbs()) * 100)) + + const showAvatars = () => bars().length <= MAX_AVATARS + // Only reserve plot halves that have bars — an all-positive window would + // otherwise leave an empty bottom half between the bars and the avatars. + const hasPositive = createMemo(() => bars().some((b) => b.percent > 0)) + const hasNegative = createMemo(() => bars().some((b) => b.percent < 0)) + + const isFull = () => start() === 0 && end() === count() - 1 + + const zoom = (dir: 1 | -1) => { + const s = start() + const e = end() + const win = e - s + const step = Math.max(1, Math.round(win * 0.15)) + if (dir === 1) { + // zoom in — shrink the window toward its center + if (win < 2) return + const mid = (s + e) / 2 + setStart(Math.min(s + step, Math.floor(mid))) + setEnd(Math.max(e - step, Math.ceil(mid))) + } else { + setStart(Math.max(0, s - step)) + setEnd(Math.min(count() - 1, e + step)) + } + } + + const pan = (dir: 1 | -1) => { + const win = end() - start() + if (dir === -1) { + const ns = Math.max(0, start() - Math.max(1, Math.round(win * 0.5))) + setStart(ns) + setEnd(ns + win) + } else { + const ne = Math.min(count() - 1, end() + Math.max(1, Math.round(win * 0.5))) + setEnd(ne) + setStart(ne - win) + } + } + + // Hovered/focused bar → single floating tooltip (old recharts tooltip). We + // track the bar plus its horizontal center relative to the strip container. + let strip: HTMLDivElement | undefined + const [hovered, setHovered] = createSignal<{ bar: Bar; left: number } | null>( + null, + ) + const showTip = (e: { currentTarget: HTMLElement }, bar: Bar) => { + if (!strip) return + const r = e.currentTarget.getBoundingClientRect() + const p = strip.getBoundingClientRect() + setHovered({ bar, left: r.left - p.left + r.width / 2 }) + } + const signed = (n: number) => (n > 0 ? `+${n}` : String(n)) + + return ( + + + {(h) => ( + + )} + + + } + > + 0} + fallback={ +
+ No evaluation impacts yet. +
+ } + > +
+ 1}> + { + setStart(0) + setEnd(count() - 1) + }} + onZoomIn={() => zoom(1)} + onZoomOut={() => zoom(-1)} + onPanLeft={() => pan(-1)} + onPanRight={() => pan(1)} + disabledZoomIn={end() - start() < 2} + disabledZoomOut={isFull()} + disabledPanLeft={start() === 0} + disabledPanRight={end() === count() - 1} + /> + + +
+ {/* Floating tooltip for the hovered/focused bar. */} + + {(h) => ( +
+

+ {h().bar.name} +

+
+ + + Score:{" "} + + {compactFormat(h().bar.score ?? 0)} + + + + + Impact:{" "} + + {signed(Number(h().bar.percent.toFixed(1)))}% + + + + Confidence:{" "} + + {signed(h().bar.confidence)} ( + {confidenceLabel(h().bar.confidence)}) + + + {formatDuration(h().bar.modified)} +
+
+ )} +
+ + + {(bar) => ( + + )} + +
+
+
+
+ ) +} diff --git a/apps/core/src/components/charts/impact-strip.tsx b/apps/core/src/components/charts/impact-strip.tsx new file mode 100644 index 0000000..f127927 --- /dev/null +++ b/apps/core/src/components/charts/impact-strip.tsx @@ -0,0 +1,56 @@ +import { createMemo, For, Show } from "solid-js" +import { barColor } from "@/components/charts/colors" +import { authStore } from "@/store/auth" +import type { AuraImpactRaw } from "@aura/domain/types/aura" + +/** + * Shared mini impact strip: a subject's top evaluations by |impact| as tiny CSS + * bars in the graded confidence palette (same colors as the big chart, so the + * subject-list and evaluation cards read consistently). Renders nothing when + * there are no impacts. + */ +export default function ImpactStrip(props: { + impacts: () => AuraImpactRaw[] | null + /** Max bars (default 8). */ + max?: number + /** Extra classes, e.g. a height (`h-10`) and margin. */ + class?: string + title?: string + testid?: string +}) { + const bars = createMemo(() => { + const limit = props.max ?? 8 + const impacts = (props.impacts() ?? []) + .filter((i) => i.impact !== 0) + .sort((a, b) => Math.abs(b.impact) - Math.abs(a.impact)) + .slice(0, limit) + const maxAbs = Math.max(...impacts.map((i) => Math.abs(i.impact)), 1) + return impacts.map((i) => ({ + color: barColor( + i.confidence * Math.sign(i.impact), + i.evaluator, + authStore.user?.brightId, + ), + height: Math.max(15, Math.round((Math.abs(i.impact) / maxAbs) * 100)), + })) + }) + + return ( + 0}> +
+ + {(bar) => ( + + )} + +
+
+ ) +} diff --git a/apps/core/src/components/charts/zoom-controls.tsx b/apps/core/src/components/charts/zoom-controls.tsx new file mode 100644 index 0000000..7ad8dc0 --- /dev/null +++ b/apps/core/src/components/charts/zoom-controls.tsx @@ -0,0 +1,67 @@ +/** + * Zoom/pan controls for the evaluations chart — ported from the old aura + * `ZoomControls`. The chart shows a sliding window over the bars; these + * buttons narrow it (zoom in), widen it (zoom out), slide it (pan) or restore + * the full range (reset). + */ +export default function ZoomControls(props: { + onReset: () => void + onZoomIn: () => void + onZoomOut: () => void + onPanLeft: () => void + onPanRight: () => void + disabledZoomIn: boolean + disabledZoomOut: boolean + disabledPanLeft: boolean + disabledPanRight: boolean +}) { + return ( +
+ props.onReset()} + > + + + props.onZoomIn()} + > + + + props.onZoomOut()} + > + + + props.onPanLeft()} + > + + + props.onPanRight()} + > + + +
+ ) +} diff --git a/apps/core/src/components/evaluation/credibility-details.tsx b/apps/core/src/components/evaluation/credibility-details.tsx new file mode 100644 index 0000000..1cccfa7 --- /dev/null +++ b/apps/core/src/components/evaluation/credibility-details.tsx @@ -0,0 +1,186 @@ +import { categoryLabel, confidenceLabel } from "@aura/domain/labels" +import { calculateUserScorePercentage, impactShare } from "@aura/domain/score" +import { EvaluationCategory } from "@aura/domain/types/evaluations" +import type { DialogElement } from "@aura/ui" +import { useNavigate } from "@solidjs/router" +import { createEffect, createMemo, createSignal, For, Show } from "solid-js" +import EvaluationsChart from "@/components/charts/evaluations-chart" +import EvaluateModal from "@/components/evaluation/evaluate-modal" +import Avatar from "@/components/home/avatar" +import ProgressBar from "@/components/home/progress-bar" +import LevelScore from "@/components/shared/level-score" +import { useSubjectName } from "@/hooks/use-backup" +import { useMyRating } from "@/hooks/use-my-evaluations" +import { useSubjectInboundEvaluations } from "@/hooks/use-subject-inbound-evaluations" +import { useSubjectVerifications } from "@/hooks/use-subject-verifications" +import { roleColor, roleIcon } from "@/shared/lib/role-style" +import { authStore } from "@/store/auth" + +/** One role's stats panel: standing, your evaluation, impacts chart. */ +function RoleStats(props: { + subjectId: () => string + category: EvaluationCategory + onNavigate: (subjectId: string) => void + onEvaluate: (category: EvaluationCategory) => void +}) { + const v = useSubjectVerifications(props.subjectId, () => props.category) + const inbound = useSubjectInboundEvaluations( + props.subjectId, + () => props.category, + ) + const progress = createMemo(() => + calculateUserScorePercentage(props.category, v.auraScore() ?? 0), + ) + + // My evaluation of this subject in this role + its share of total impact. + const my = useMyRating(props.subjectId, () => props.category) + const myImpactPercent = createMemo(() => + impactShare(v.auraImpacts(), authStore.user?.brightId), + ) + const isSelf = () => props.subjectId() === authStore.user?.brightId + + return ( +
+ +

+ Evaluations:{" "} + + {inbound.evaluations()?.length ?? "…"} + {" "} + ({inbound.positiveCount() ?? "…"} pos / {inbound.negativeCount() ?? "…"}{" "} + neg) +

+ = 0}> + + + +
+ + Your evaluation:{" "} + + 0 ? "text-aura-success" : "text-destructive"}`} + > + {(my.rating()! > 0 ? "+" : "") + my.rating()}{" "} + {confidenceLabel(my.rating()!)} + + + · {myImpactPercent()}% impact + + + + + props.onEvaluate(props.category)} + > + + Edit + + + +
+ + v.auraImpacts()} + onBarClick={props.onNavigate} + loading={() => v.loading()} + /> +
+ ) +} + +export default function CredibilityDetails(props: { + subjectId: () => string | null + onClose: () => void +}) { + let dialog: DialogElement | undefined + const navigate = useNavigate() + + const id = () => props.subjectId() ?? "" + const name = useSubjectName(id) + + const roleChecks = Object.values(EvaluationCategory).map((category) => ({ + category, + v: useSubjectVerifications(id, () => category), + })) + const authorizedRoles = createMemo(() => + roleChecks + .filter( + ({ category, v }) => + category === EvaluationCategory.SUBJECT || (v.auraLevel() ?? 0) > 0, + ) + .map(({ category }) => category), + ) + + const [evaluatingCategory, setEvaluatingCategory] = + createSignal(null) + + const goTo = (subjectId: string) => { + dialog?.hide() + navigate(`/subject/${subjectId}`) + } + createEffect(() => { + if (props.subjectId()) dialog?.show() + else dialog?.hide() + }) + + return ( + <> + {/* State clears only after the leave animation completes, so the body + never re-renders to its empty state mid-exit. */} + props.onClose()}> +
+
+ +

{name()}

+
+ + {/* keyed: remount the tabs (and reset the active one) per subject */} + + + + {(category) => ( + + + {categoryLabel[category]} + + )} + + + {(category) => ( + + + + )} + + + + goTo(id())} + > + View profile + +
+
+ + (evaluatingCategory() ? id() : null)} + category={() => evaluatingCategory() ?? EvaluationCategory.SUBJECT} + onClose={() => setEvaluatingCategory(null)} + /> + + ) +} diff --git a/apps/core/src/components/evaluation/evaluate-modal.tsx b/apps/core/src/components/evaluation/evaluate-modal.tsx new file mode 100644 index 0000000..d2cd9eb --- /dev/null +++ b/apps/core/src/components/evaluation/evaluate-modal.tsx @@ -0,0 +1,243 @@ +import { + CONFIDENCE_LABELS, + categoryLabel, + confidenceLabel, +} from "@aura/domain/labels" +import { EvaluationCategory } from "@aura/domain/types/evaluations" +import type { DialogElement } from "@aura/ui" +import { toast } from "@aura/ui" +import { createEffect, createSignal, For, Show } from "solid-js" +import { useSubjectName } from "@/hooks/use-backup" +import { useEvaluateSubject } from "@/hooks/use-evaluate-subject" +import { useMyRating } from "@/hooks/use-my-evaluations" +import { useViewMode } from "@/hooks/use-view-mode" + +const CONFIDENCE_VALUES = Object.keys(CONFIDENCE_LABELS).map(Number) + +/** Question + expression copy ported from the old app's translations. */ +const QUESTIONS: Record = { + [EvaluationCategory.SUBJECT]: + "Is this the account of {name} that should be Aura verified?", + [EvaluationCategory.PLAYER]: + "Does this Player accurately and honestly evaluate Subjects in the BrightID domain?", + [EvaluationCategory.TRAINER]: + "Does this Trainer accurately and honestly evaluate Players in the BrightID domain?", + [EvaluationCategory.MANAGER]: + "Does this Manager accurately and honestly evaluate Managers and Trainers in the BrightID domain?", +} + +const EXPRESSIONS: Record< + EvaluationCategory, + { positive: string; negative: string } +> = { + [EvaluationCategory.SUBJECT]: { + positive: "… this account should be verified.", + negative: "… this account should not be verified.", + }, + [EvaluationCategory.PLAYER]: { + positive: "… this Player accurately and honestly evaluates Subjects.", + negative: + "… this Player does not accurately and honestly evaluate Subjects.", + }, + [EvaluationCategory.TRAINER]: { + positive: "… this Trainer accurately and honestly evaluates Players.", + negative: + "… this Trainer does not accurately and honestly evaluate Players.", + }, + [EvaluationCategory.MANAGER]: { + positive: + "… this Manager accurately and honestly evaluates Managers and Trainers.", + negative: + "… this Manager does not accurately and honestly evaluate Managers and Trainers.", + }, +} + +export default function EvaluateModal(props: { + subjectId: () => string | null + onClose: () => void + category?: () => EvaluationCategory +}) { + let dialog: DialogElement | undefined + + const vm = useViewMode() + const category = () => props.category?.() ?? vm.currentEvaluationCategory() + + const [isYes, setIsYes] = createSignal(true) + const [confidence, setConfidence] = createSignal(1) + const [onDelete, setOnDelete] = createSignal(false) + + const { submitEvaluation, isPending } = useEvaluateSubject(category) + const name = useSubjectName(() => props.subjectId() ?? "") + // Existing rating for this subject in this category — seeds the form. + const existing = useMyRating(() => props.subjectId() ?? "", category) + + createEffect(() => { + const id = props.subjectId() + if (id) { + const prev = existing.rating() + setIsYes(prev === undefined ? true : prev > 0) + setConfidence(prev === undefined ? 1 : Math.abs(prev) || 1) + setOnDelete(false) + dialog?.show() + } else { + dialog?.hide() + } + }) + + const close = () => dialog?.hide() + + const fail = (e: unknown) => + toast.error("Error", { + description: + "Failed to submit evaluation" + + (e instanceof Error ? `: ${e.message}` : String(e)), + duration: 5000, + }) + + const submit = async () => { + const id = props.subjectId() + if (!id || isPending()) return + const updating = existing.rating() !== undefined + try { + await submitEvaluation(id, isYes() ? confidence() : -confidence()) + toast.success(updating ? "Evaluation updated" : "Evaluation submitted", { + description: name(), + }) + close() + } catch (e) { + fail(e) + } + } + + // Two-step remove: first tap arms it, second submits a confidence-0 op. + const remove = async () => { + const id = props.subjectId() + if (!id || isPending()) return + if (!onDelete()) return setOnDelete(true) + try { + await submitEvaluation(id, 0) + toast.success("Evaluation removed", { description: name() }) + close() + } catch (e) { + fail(e) + } + } + + return ( + props.onClose()}> +
+
+ + Evaluate {name()} as a{" "} + {categoryLabel[category()]} in the{" "} + BrightID domain + +

+ {QUESTIONS[category()].replace("{name}", name())} +

+
+ +
+ setIsYes(true)} + > + Yes + + setIsYes(false)} + > + No + +
+ +
+ How confident are you? +
+ + {(value) => ( + setConfidence(value)} + > + {confidenceLabel(value)} + + )} + +
+

+ I'm{" "} + + {confidenceLabel(confidence())} + {" "} + confident that{" "} + {EXPRESSIONS[category()][isYes() ? "positive" : "negative"]} +

+
+ + + dialog?.hide()} + > + Cancel + + + + Sending… + + +
+ } + > +
+ + + Sending… + + + + + + Remove + + +
+ + +
+ ) +} diff --git a/apps/core/src/components/evaluation/evaluation-card.tsx b/apps/core/src/components/evaluation/evaluation-card.tsx new file mode 100644 index 0000000..e290378 --- /dev/null +++ b/apps/core/src/components/evaluation/evaluation-card.tsx @@ -0,0 +1,65 @@ +import { Show } from "solid-js" +import ImpactStrip from "@/components/charts/impact-strip" +import Avatar from "@/components/home/avatar" +import LevelScore from "@/components/shared/level-score" +import { formatDuration } from "@/shared/lib/time" +import type { InboundEvaluation } from "@/hooks/use-subject-inbound-evaluations" +import { confidenceLabel } from "@aura/domain/labels" + +/** + * Inbound-evaluation row: evaluator (photo, name, level), signed rating chip + * with confidence, impact share and age. + */ +export default function EvaluationCard(props: { + evaluation: InboundEvaluation + onClick?: (evaluatorId: string) => void +}) { + const e = () => props.evaluation + const positive = () => e().rating > 0 + + return ( + props.onClick?.(e().evaluatorId)} + > +
+ +
+

{e().name}

+ +

+ {formatDuration(e().timestamp)} +

+
+
+ +
+ + + {(positive() ? "+" : "") + e().rating} + {confidenceLabel(e().confidence)} + + + + {e().impactPercent}% of impact + + + e().impacts} + class="mt-1 h-10" + title={`Top evaluations of ${e().name}`} + testid={`evaluation-card-${e().evaluatorId}-chart`} + /> +
+
+ ) +} diff --git a/apps/core/src/components/evaluation/op-notifications.tsx b/apps/core/src/components/evaluation/op-notifications.tsx new file mode 100644 index 0000000..ed1c209 --- /dev/null +++ b/apps/core/src/components/evaluation/op-notifications.tsx @@ -0,0 +1,109 @@ +import { fetchOperationState } from "@aura/domain/operations" +import type { OperationState } from "@aura/domain/types/evaluations" +import { toast } from "@aura/ui" +import { useQueryClient } from "@tanstack/solid-query" +import { createEffect, createMemo, onCleanup } from "solid-js" +import { useBackup } from "@/hooks/use-backup" +import { NODE_API_BASE } from "@/shared/lib/api" +import { operationsStore, setOperationState } from "@/store/operations" + +/** How often to re-check pending ops against the node (old OPERATION_TRACE_TIME). */ +const POLL_INTERVAL_MS = 5_000 + +/** States that are still in flight and worth polling. */ +const PENDING_STATES: ReadonlySet = new Set([ + "INIT", + "SENT", + "UNKNOWN", +]) + +/** + * Always-on poller + toaster for submitted evaluations (T1.6). While any op in + * the operations store is still pending (`INIT`/`SENT`/`UNKNOWN`), it polls each + * one's state on an interval, writes changes back to the store, and toasts once + * per op when it settles to `APPLIED` (success) or `FAILED`/`EXPIRED` (error). + * + * Renders nothing — mount it once at app root. Pairs with T1.5's optimistic + * overlay: the overlay shows the rating immediately at `INIT`; once `APPLIED` + * the invalidated server reads take over and the overlay becomes a no-op. + * + * Replaces the old `EvaluationOpNotifications` rich UI with plain toasts. + */ +export default function OpNotifications() { + const backup = useBackup() + const queryClient = useQueryClient() + + // Resolve a subject's display name from the backup, falling back to a short id. + const subjectName = (id: string): string => { + const data = backup.data + if (!data) return id.slice(0, 7) + const info = + id === data.userData.id + ? data.userData + : data.connections.find((c) => c.id === id) + return info?.name ?? id.slice(0, 7) + } + + // Hashes we've already toasted on, so each transition fires exactly once. + const notified = new Set() + + const pendingHashes = createMemo(() => + Object.values(operationsStore.byHash) + .filter((op) => PENDING_STATES.has(op.state)) + .map((op) => op.hash), + ) + + const poll = async () => { + for (const hash of pendingHashes()) { + const op = operationsStore.byHash[hash] + if (!op) continue + let next: OperationState + try { + next = await fetchOperationState(NODE_API_BASE, hash) + } catch { + // Transient node/transport error — leave the op pending and retry next tick. + continue + } + if (next === op.state) continue + + setOperationState(hash, next) + + // The op only lands on the node's reads once it APPLIES — the + // submit-time invalidation in `createEvaluateMutation` refetched while + // the node still held the old data. Re-invalidate now so the fresh + // rating/impacts replace the optimistic overlay. + if (next === "APPLIED") { + queryClient.invalidateQueries({ + queryKey: ["brightid-profile", op.evaluated], + }) + queryClient.invalidateQueries({ + queryKey: ["connections", "outbound", op.evaluator], + }) + } + + if (notified.has(hash)) continue + if (next === "APPLIED") { + notified.add(hash) + toast.success("Evaluation submitted", { + description: subjectName(op.evaluated), + }) + } else if (next === "FAILED" || next === "EXPIRED") { + notified.add(hash) + toast.error("Evaluation failed", { + description: subjectName(op.evaluated), + }) + } + } + } + + // Run the interval only while there are pending ops; tear it down otherwise so + // we never poll an idle node. The effect re-runs when pending count changes. + createEffect(() => { + if (pendingHashes().length === 0) return + const timer = setInterval(poll, POLL_INTERVAL_MS) + void poll() + onCleanup(() => clearInterval(timer)) + }) + + return null +} diff --git a/apps/core/src/components/home/avatar.tsx b/apps/core/src/components/home/avatar.tsx new file mode 100644 index 0000000..dccafe2 --- /dev/null +++ b/apps/core/src/components/home/avatar.tsx @@ -0,0 +1,85 @@ +import { createMemo, type JSX, Show } from "solid-js" +import { createProfilePhotoQuery } from "@/queries/backup" +import { authStore } from "@/store/auth" +import { hash } from "@aura/domain/crypto" + +/** + * Avatar with the real BrightID profile photo when available (decrypted from + * the recovery backup — needs a password session, so passkey users get the + * initials fallback). Pass `subjectId` to enable the photo and the hover + * preview: hovering shows the enlarged image, like the old + * `BrightIdProfilePicture`. + */ +export default function Avatar(props: { + name: string + subjectId?: string + /** Disable the enlarged hover preview (e.g. inside dialogs). */ + noHover?: boolean + class?: string + style?: JSX.CSSProperties + /** Image URL used when the backup has no photo (e.g. a gravatar link). */ + fallbackSrc?: string +}) { + const initial = () => (props.name?.trim()?.[0] ?? "?").toUpperCase() + + // Photos are stored per-connection in the logged-in user's backup. + const authKey = createMemo(() => { + const user = authStore.user + return user?.brightId && user.password + ? hash(user.brightId + user.password) + : "" + }) + const photo = createProfilePhotoQuery( + authKey, + () => props.subjectId ?? "", + () => authStore.user?.password ?? "", + ) + // Backup photos are data URIs; tolerate raw base64 just in case. + const src = () => { + const data = photo.data + if (!data) return props.fallbackSrc + return data.startsWith("data:") ? data : `data:image/jpeg;base64,${data}` + } + + const Circle = () => ( + + {initial()} + + } + > + {props.name} + + ) + + return ( + }> + + + + +
+ {props.name} +

+ {props.name} +

+
+
+
+ ) +} diff --git a/apps/core/src/components/home/home-header.tsx b/apps/core/src/components/home/home-header.tsx new file mode 100644 index 0000000..339a0bc --- /dev/null +++ b/apps/core/src/components/home/home-header.tsx @@ -0,0 +1,119 @@ +import { A } from "@solidjs/router" +import { For, Show } from "solid-js" +import GlobalSearch from "@/components/search/global-search" +import NotificationBell from "@/components/shared/notification-bell" +import { useLevelupProgress } from "@/hooks/use-levelup-progress" +import { useViewMode } from "@/hooks/use-view-mode" +import { roleColor, roleIcon } from "@/shared/lib/role-style" +import { + viewModeToSlug, + viewModeToString, + viewModeToViewAs, +} from "@aura/domain/view-mode" +import { PreferredView } from "@aura/domain/types/dashboard" +import { EvaluationCategory } from "@aura/domain/types/evaluations" + +/** Views in the switcher, paired with the category that gates each one. */ +const VIEWS: { view: PreferredView; gate: EvaluationCategory | null }[] = [ + { view: PreferredView.PLAYER, gate: null }, + { view: PreferredView.TRAINER, gate: EvaluationCategory.TRAINER }, + { + view: PreferredView.MANAGER_EVALUATING_TRAINER, + gate: EvaluationCategory.MANAGER, + }, +] + +/** + * Home header with role-view switcher — each view is its own route + * (`/home/:view`). Trainer/Manager stay disabled until unlocked, mirroring the + * "Level Up" tab gating in `home/[view]/_layout.tsx`. + */ +export default function HomeHeader() { + const vm = useViewMode() + const trainerProgress = useLevelupProgress(() => EvaluationCategory.TRAINER) + const managerProgress = useLevelupProgress(() => EvaluationCategory.MANAGER) + + const isUnlocked = (gate: EvaluationCategory | null) => { + if (gate === EvaluationCategory.TRAINER) return trainerProgress().isUnlocked + if (gate === EvaluationCategory.MANAGER) return managerProgress().isUnlocked + return true + } + + const lockMessage = (gate: EvaluationCategory | null) => + gate === EvaluationCategory.MANAGER + ? "Reach the required standing as a Trainer to unlock the Manager view." + : "Reach the required standing as a Player to unlock the Trainer view." + + return ( +
+
+ Home +
+ + + + + + + +
+
+ + {/* View switcher — scrolls horizontally instead of overflowing on + narrow screens; each button keeps its intrinsic width. */} +
+ + {({ view, gate }) => { + const label = viewModeToString[view] + const selected = () => vm.currentViewMode() === view + return ( + + + + +
+

{label} locked

+ + {lockMessage(gate)} + +
+ + } + > + + + + {/* Only the active role shows its label — keeps the + switcher compact yet always says where you are. */} + + {label} + + + +
+ ) + }} +
+
+
+ ) +} diff --git a/apps/core/src/components/home/level-progress.tsx b/apps/core/src/components/home/level-progress.tsx new file mode 100644 index 0000000..016318b --- /dev/null +++ b/apps/core/src/components/home/level-progress.tsx @@ -0,0 +1,216 @@ +import { createMemo, createSignal, Match, Show, Switch } from "solid-js" +import Skeleton from "@/components/shared/skeleton" +import RequirementsChecklist, { + type LevelRequirement, +} from "@/components/home/requirements-checklist" +import { useMyEvaluations } from "@/hooks/use-my-evaluations" +import { useSubjectVerifications } from "@/hooks/use-subject-verifications" +import { useViewMode } from "@/hooks/use-view-mode" +import { compactFormat } from "@/shared/lib/number" +import { playerLevelPoints } from "@aura/domain/levels" +import { + calculateRemainingScoreToNextLevel, + calculateUserScorePercentage, +} from "@aura/domain/score" +import { EvaluationCategory } from "@aura/domain/types/evaluations" + +const PLAYER_EVALUATION_MINIMUM_COUNT_BEFORE_TRAINING = 3 + +type NextLevelStatus = { + isPassed: boolean + reason: string + progress?: number + checklists?: LevelRequirement[] +} + +/** Progress toward the subject's next level in the evaluator category. */ +export default function LevelProgress(props: { subjectId: string }) { + const vm = useViewMode() + const category = vm.currentRoleEvaluatorEvaluationCategory + const [showRequirements, setShowRequirements] = createSignal(false) + + const v = useSubjectVerifications(() => props.subjectId, category) + const { myRatings } = useMyEvaluations() + + const remainingScore = createMemo(() => + calculateRemainingScoreToNextLevel(category(), v.auraScore() ?? 0), + ) + + const nextLevel = createMemo(() => { + if (category() !== EvaluationCategory.PLAYER) { + return { isPassed: true, reason: "" } + } + + const impacts = v.auraImpacts() ?? [] + const medium = impacts.filter((i) => i.confidence > 1) + const high = impacts.filter((i) => i.confidence > 2) + const level = v.auraLevel() + const score = v.auraScore() ?? 0 + + if (level === 1) { + return { + progress: 0, + isPassed: medium.filter((i) => (i.level ?? 0) >= 1).length > 0, + reason: "1 Medium+ confidence evaluation from one level 1+ trainer", + checklists: [ + { title: "Score: 2M+", requirement: playerLevelPoints[2] - score }, + { + title: "1 Medium+ confidence evaluation from one level 1+ trainer", + requirement: 1 - medium.filter((i) => (i.level ?? 0) >= 1).length, + }, + ], + } + } + + if (level === 2) { + const hasOneHigh = high.filter((i) => (i.level ?? 0) >= 2).length >= 1 + const hasTwoMedium = medium.filter((i) => (i.level ?? 0) >= 2).length >= 2 + return { + isPassed: hasOneHigh || hasTwoMedium, + reason: "2 Medium+ confidence evaluation from level 2+ trainers", + progress: (medium.length + high.length) / 3, + checklists: [ + { title: "Score: 3M+", requirement: playerLevelPoints[3] - score }, + { + OR: [ + { + title: "2 Medium+ confidence evaluation from level 2+ trainers", + requirement: 2 - medium.length, + }, + { + title: "1 High+ confidence evaluation from one level 2+ trainer", + requirement: 1 - high.length, + }, + ], + }, + ], + } + } + + return { isPassed: true, reason: "" } + }) + + const ratingsToBeDone = createMemo(() => { + const r = myRatings() + if (!r) return undefined + return Math.max( + PLAYER_EVALUATION_MINIMUM_COUNT_BEFORE_TRAINING - + r.filter((x) => Number(x.rating)).length, + 0, + ) + }) + + const progressPercentage = createMemo(() => { + const progress = nextLevel().progress + if (progress) return Math.floor(progress * 100) + const todo = ratingsToBeDone() + if (!todo) return 0 + return Math.floor( + ((PLAYER_EVALUATION_MINIMUM_COUNT_BEFORE_TRAINING - todo) * 100) / + PLAYER_EVALUATION_MINIMUM_COUNT_BEFORE_TRAINING, + ) + }) + + const levelPercentage = createMemo(() => + calculateUserScorePercentage(category(), v.auraScore() ?? 0), + ) + + const barWidth = createMemo(() => + remainingScore() > 0 || nextLevel().progress + ? progressPercentage() + : levelPercentage(), + ) + + const nextLevelLabel = () => `Level ${(v.auraLevel() ?? 0) + 1}` + + return ( + +
+ +
+
Level
+
+ {v.auraLevel() ?? "-"} +
+
+
+ +
+
+ +

+ {nextLevel().reason} + + + +

+ to + + {nextLevelLabel()} + + + } + > + + + + + + {compactFormat(Math.abs(v.auraScore() ?? 0))} + + to + Level 1 + + 0}> + + {compactFormat(remainingScore())} + + to + + {nextLevelLabel()} + + + + + You've reached the maximum level! 🎉 + + +
+
+ +
+ + score:{" "} + + {compactFormat(v.auraScore() ?? 0)} + + +
+
+
+
+ + + {(checklists) => ( +
+
+ Requirements for the next level +
+ +
+ )} +
+ + ) +} diff --git a/apps/core/src/components/home/profile-header-card.tsx b/apps/core/src/components/home/profile-header-card.tsx new file mode 100644 index 0000000..864d56f --- /dev/null +++ b/apps/core/src/components/home/profile-header-card.tsx @@ -0,0 +1,44 @@ +import { createMemo, Show } from "solid-js" +import Avatar from "@/components/home/avatar" +import ProgressBar from "@/components/home/progress-bar" +import LevelScore from "@/components/shared/level-score" +import { useSubjectName } from "@/hooks/use-backup" +import { useSubjectVerifications } from "@/hooks/use-subject-verifications" +import { useViewMode } from "@/hooks/use-view-mode" +import { calculateUserScorePercentage } from "@aura/domain/score" + +export default function ProfileHeaderCard(props: { subjectId: string }) { + const name = useSubjectName(() => props.subjectId) + const vm = useViewMode() + const v = useSubjectVerifications( + () => props.subjectId, + vm.currentRoleEvaluatorEvaluationCategory, + ) + const progress = createMemo(() => + calculateUserScorePercentage( + vm.currentRoleEvaluatorEvaluationCategory(), + v.auraScore() ?? 0, + ), + ) + + return ( + +
+ +
+

+ {name()} +

+ + = 0} fallback={😈}> + + +
+
+
+ ) +} diff --git a/apps/core/src/components/home/profile-info-performance.tsx b/apps/core/src/components/home/profile-info-performance.tsx new file mode 100644 index 0000000..2c45ef6 --- /dev/null +++ b/apps/core/src/components/home/profile-info-performance.tsx @@ -0,0 +1,60 @@ +import { Show } from "solid-js" +import ProgressBar from "@/components/home/progress-bar" +import { useLevelupProgress } from "@/hooks/use-levelup-progress" +import { useSubjectVerifications } from "@/hooks/use-subject-verifications" +import { useViewMode } from "@/hooks/use-view-mode" +import { compactFormat } from "@/shared/lib/number" +import { EvaluationCategory } from "@aura/domain/types/evaluations" + +/** Level-up progress toward the evaluator role. Hidden once unlocked. */ +export default function ProfileInfoPerformance(props: { subjectId: string }) { + const vm = useViewMode() + const category = vm.currentRoleEvaluatorEvaluationCategory + const v = useSubjectVerifications(() => props.subjectId, category) + const progress = useLevelupProgress(category) + + const percent = () => Math.max(0, Math.min(100, progress().percent)) + + return ( + + +
+
+ + + +
+

+ Level Up locked +

+

{progress().reason}

+
+
+ + Score{" "} + + {compactFormat(v.auraScore() ?? 0)} + + +
+ +
+ + + {Math.round(percent())}% + +
+ + +

+ Evaluate subjects below to make progress. +

+
+
+
+ ) +} diff --git a/apps/core/src/components/home/profile-not-found-hint.tsx b/apps/core/src/components/home/profile-not-found-hint.tsx new file mode 100644 index 0000000..328ebbf --- /dev/null +++ b/apps/core/src/components/home/profile-not-found-hint.tsx @@ -0,0 +1,58 @@ +import { toast } from "@aura/ui" + +/** + * Shown when a profile/connections lookup 404s: the aura node only creates a + * profile once the user receives their first evaluation, so nudge them to + * share the profile (or, for other subjects, to evaluate them). + */ +export default function ProfileNotFoundHint(props: { + subjectId: string + /** Whether the missing profile is the logged-in user's own. */ + self?: boolean +}) { + const share = async () => { + const url = `${window.location.origin}/subject/${props.subjectId}` + try { + if (navigator.share) { + await navigator.share({ title: "Aura profile", url }) + return + } + await navigator.clipboard?.writeText(url) + toast.success("Profile link copied", { + description: "Send it to a connection and ask for an evaluation.", + }) + } catch { + /* user dismissed the share sheet */ + } + } + + return ( + +
+ +

+ {props.self ? "No Aura profile yet" : "This subject has no profile yet"} +

+
+ + {props.self + ? "Your profile is created when another player evaluates you. " + + "Share your profile link with a connection and ask for your first evaluation." + : "A profile is created once someone evaluates this subject — " + + "evaluate them or share their profile to get them started."} + + + Share profile + +
+ ) +} diff --git a/apps/core/src/components/home/progress-bar.tsx b/apps/core/src/components/home/progress-bar.tsx new file mode 100644 index 0000000..403cc6c --- /dev/null +++ b/apps/core/src/components/home/progress-bar.tsx @@ -0,0 +1,16 @@ +export default function ProgressBar(props: { + percentage: number + class?: string +}) { + const pct = () => Math.max(0, Math.min(100, props.percentage)) + return ( +
+
+
+ ) +} diff --git a/apps/core/src/components/home/requirements-checklist.tsx b/apps/core/src/components/home/requirements-checklist.tsx new file mode 100644 index 0000000..c6948a8 --- /dev/null +++ b/apps/core/src/components/home/requirements-checklist.tsx @@ -0,0 +1,72 @@ +import { For, Show } from "solid-js" +import { compactFormat } from "@/shared/lib/number" + +export type BaseRequirement = { title: string; requirement: number } +export type LevelRequirement = + | BaseRequirement + | { OR?: BaseRequirement[]; AND?: BaseRequirement[] } + +const isBase = (item: LevelRequirement): item is BaseRequirement => + "title" in item + +/** One checklist row, or a nested AND/OR group of rows. */ +function ChecklistItem(props: { item: LevelRequirement }) { + return ( + + } + > + {(base) => ( +
+ + + {base().title}{" "} + 0}> + ({compactFormat(base().requirement)} more needed) + + +
+ )} +
+ ) +} + +function GroupItem(props: { + item: { OR?: BaseRequirement[]; AND?: BaseRequirement[] } +}) { + const children = () => props.item.AND ?? props.item.OR ?? [] + const separator = () => (props.item.AND ? "(AND)" : "(OR)") + return ( +
+ + {(child, index) => ( + <> + + +

{separator()}

+
+ + )} +
+
+ ) +} + +/** Requirements for reaching the next level, with met/unmet indicators. */ +export default function RequirementsChecklist(props: { + checklists: LevelRequirement[] +}) { + return ( +
+ {(item) => } +
+ ) +} diff --git a/apps/core/src/components/home/subject-card.tsx b/apps/core/src/components/home/subject-card.tsx new file mode 100644 index 0000000..7aca7a5 --- /dev/null +++ b/apps/core/src/components/home/subject-card.tsx @@ -0,0 +1,151 @@ +import { useNavigate } from "@solidjs/router" +import { createMemo, Show } from "solid-js" +import ImpactStrip from "@/components/charts/impact-strip" +import Avatar from "@/components/home/avatar" +import ProgressBar from "@/components/home/progress-bar" +import LevelScore from "@/components/shared/level-score" +import { useMyRating } from "@/hooks/use-my-evaluations" +import { useSubjectVerifications } from "@/hooks/use-subject-verifications" +import { useViewMode } from "@/hooks/use-view-mode" +import { toTitleCase } from "@/shared/lib/text" +import { authStore } from "@/store/auth" +import { confidenceLabel } from "@aura/domain/labels" +import { calculateUserScorePercentage, impactShare } from "@aura/domain/score" +import type { + BrightIdBackupConnection, + ConnectionLevel, +} from "@aura/domain/types/aura" + +/** Old app's connection-level palette — colors the level chip's icon + text. */ +const LEVEL_COLORS: Record = { + reported: "#FF4B31", + suspicious: "#FF7831", + recovery: "#FFA131", + "already known": "#FFC585", + "just met": "#FFB85C", + "aura only": "#FFE8D4", +} + +/** Lucide icon per connection level (old app showed a level icon in the chip). */ +const LEVEL_ICONS: Record = { + reported: "shield-alert", + suspicious: "shield-alert", + "just met": "user", + "already known": "user-check", + recovery: "shield-check", + "aura only": "sparkles", +} + +/** + * Subject row, ported from the old `SubjectCard`: avatar, name, level/score, + * score progress, connection-level + your-evaluation status chips and a small + * impact strip (lightweight CSS bars instead of the old echarts mini chart). + */ +export default function SubjectCard(props: { + connection: BrightIdBackupConnection + onEvaluate?: (id: string) => void +}) { + const navigate = useNavigate() + const subjectId = () => props.connection.id + const name = () => props.connection.name || subjectId().slice(0, 7) + + const vm = useViewMode() + const v = useSubjectVerifications(subjectId, vm.currentEvaluationCategory) + const my = useMyRating(subjectId, vm.currentEvaluationCategory) + + const progress = createMemo(() => + calculateUserScorePercentage( + vm.currentEvaluationCategory(), + v.auraScore() ?? 0, + ), + ) + const myImpactPercent = createMemo(() => + impactShare(v.auraImpacts(), authStore.user?.brightId), + ) + + return ( + navigate(`/subject/${subjectId()}`)} + > +
+
+ +
+

{name()}

+ + = 0} fallback={😈}> + + +
+
+ + {/* Connection level + my evaluation status */} +
+ + + {toTitleCase(props.connection.level)} + + + + {(rating) => ( + 0 + ? "bg-aura-success/15 text-aura-success" + : "bg-destructive/15 text-destructive" + }`} + > + 0 ? "thumbs-up" : "thumbs-down"} /> + {(rating() > 0 ? "+" : "") + rating()} + } + > + {confidenceLabel(rating())} + + · {myImpactPercent()}% + + + + )} + +
+
+ +
+ v.auraImpacts()} + class="h-12" + title={`Top evaluations of ${name()}`} + testid={`subject-card-${subjectId()}-chart`} + /> + { + e.stopPropagation() + props.onEvaluate?.(subjectId()) + }} + > + + Edit + + +
+
+ ) +} diff --git a/apps/core/src/components/home/subject-list-controls.tsx b/apps/core/src/components/home/subject-list-controls.tsx new file mode 100644 index 0000000..ca8d869 --- /dev/null +++ b/apps/core/src/components/home/subject-list-controls.tsx @@ -0,0 +1,153 @@ +import { For, Show } from "solid-js" +import type { useSubjectsList } from "@/hooks/use-subjects-list" +import type { SubjectRatedState, SubjectSort } from "@/hooks/use-subjects-list" +import { toTitleCase } from "@/shared/lib/text" +import type { ConnectionLevel } from "@aura/domain/types/aura" +import type { DialogElement } from "@aura/ui" + +type Controls = ReturnType + +const SORTS: { id: SubjectSort; label: string }[] = [ + { id: "recency", label: "Recency" }, + { id: "name", label: "Name" }, +] + +const RATED_STATES: { id: SubjectRatedState; label: string }[] = [ + { id: "all", label: "All" }, + { id: "unrated", label: "Unrated" }, + { id: "rated", label: "Rated" }, +] + +/** + * Search + filter + sort controls for the subjects list. Collapses the old + * three-modal design (SubjectListControls + FiltersModal + SortsModal) into a + * single inline search box and one `` holding the filter/sort toggles. + */ +export default function SubjectListControls(props: { + controls: Controls + count: number +}) { + let dialog: DialogElement | undefined + + const c = () => props.controls + const activeFilters = () => + c().levels().length + (c().ratedState() !== "all" ? 1 : 0) + + return ( +
+ ) => c().setSearch(e.detail)} + > + + + +
+ + + + + Filter & sort + 0}> + + {activeFilters()} + + + + + +
+
+ Sort by +
+ + {(s) => ( + c().setSort(s.id)} + > + {s.label} + + )} + +
+
+ +
+ Your evaluation +
+ + {(r) => ( + c().setRatedState(r.id)} + > + {r.label} + + )} + +
+
+ +
+ Connection level +
+ + {(level: ConnectionLevel) => ( + c().toggleLevel(level)} + > + {toTitleCase(level)} + + )} + +
+
+ +
+ c().reset()} + > + Clear + + dialog?.hide()} + > + Done + +
+
+
+ + + {props.count} result{props.count === 1 ? "" : "s"} + +
+
+ ) +} diff --git a/apps/core/src/components/list/incremental-list.tsx b/apps/core/src/components/list/incremental-list.tsx new file mode 100644 index 0000000..d88a14b --- /dev/null +++ b/apps/core/src/components/list/incremental-list.tsx @@ -0,0 +1,56 @@ +import { createEffect, createSignal, For, type JSX, onCleanup } from "solid-js" + +const DEFAULT_PAGE_SIZE = 20 + +/** + * ``-based incremental list (the old `InfiniteScrollLocal`, Solid-native): + * renders a growing window over `items` and reveals the next `pageSize` batch + * when a bottom sentinel scrolls into view. All data is already in memory — + * this only paces rendering so a long list doesn't mount hundreds of rows at + * once. Reconciliation is by item reference, like Solid's ``. + */ +export default function IncrementalList(props: { + items: T[] + children: (item: T, index: () => number) => JSX.Element + /** Rows revealed per batch (default 20). */ + pageSize?: number + /** Class for the wrapping container (e.g. `flex flex-col gap-3`). */ + class?: string +}) { + const pageSize = () => props.pageSize ?? DEFAULT_PAGE_SIZE + const [limit, setLimit] = createSignal(pageSize()) + const [sentinel, setSentinel] = createSignal() + + // Collapse the window back to one page when the list itself changes + // (filter / sort / navigation), so we don't keep a stale large limit. + createEffect(() => { + props.items + setLimit(pageSize()) + }) + + const visible = () => props.items.slice(0, limit()) + const hasMore = () => limit() < props.items.length + + // Re-observe whenever the window grows: IntersectionObserver fires once on + // observe, so reconnecting after each batch keeps filling until the sentinel + // leaves the viewport (handles tall screens / small pages without scrolling). + createEffect(() => { + const el = sentinel() + limit() + if (!el || !hasMore()) return + const observer = new IntersectionObserver((entries) => { + if (entries.some((e) => e.isIntersecting)) { + setLimit((n) => Math.min(n + pageSize(), props.items.length)) + } + }) + observer.observe(el) + onCleanup(() => observer.disconnect()) + }) + + return ( +
+ {props.children} + + ) +} diff --git a/apps/core/src/components/list/list-skeleton.tsx b/apps/core/src/components/list/list-skeleton.tsx new file mode 100644 index 0000000..d0faf7b --- /dev/null +++ b/apps/core/src/components/list/list-skeleton.tsx @@ -0,0 +1,27 @@ +import { For } from "solid-js" +import Skeleton from "@/components/shared/skeleton" + +/** + * Placeholder rows shown while a subject / evaluation / connection list loads. + * Shape mirrors a card row (avatar + name/level/bar + trailing action) so the + * layout doesn't jump when the real data arrives. + */ +export default function ListSkeleton(props: { rows?: number }) { + return ( +
+ + {() => ( +
+ +
+ + + +
+ +
+ )} +
+
+ ) +} diff --git a/apps/core/src/components/list/list-state.tsx b/apps/core/src/components/list/list-state.tsx new file mode 100644 index 0000000..5d96273 --- /dev/null +++ b/apps/core/src/components/list/list-state.tsx @@ -0,0 +1,31 @@ +import { type JSX, Show } from "solid-js" +import ListSkeleton from "@/components/list/list-skeleton" + +/** Shared loading / empty / content scaffolding for list views. */ +export default function ListState(props: { + loading: boolean + empty: boolean + emptyText: string + children: JSX.Element + /** Override the loading placeholder (defaults to skeleton rows). */ + skeleton?: JSX.Element +}) { + return ( + } + > + + + {props.emptyText} +
+ } + > + {props.children} + + + ) +} diff --git a/apps/core/src/components/motions/fade-in.tsx b/apps/core/src/components/motions/fade-in.tsx new file mode 100644 index 0000000..2c98ebd --- /dev/null +++ b/apps/core/src/components/motions/fade-in.tsx @@ -0,0 +1,17 @@ +import type { ParentProps } from "solid-js" +import { Motion } from "solid-motionone" + +export interface FadeInProps extends ParentProps { + delay: number +} + +export default function FadeIn({ children, delay }: FadeInProps) { + return ( + + {children} + + ) +} diff --git a/apps/core/src/components/motions/scale.tsx b/apps/core/src/components/motions/scale.tsx new file mode 100644 index 0000000..069f1ba --- /dev/null +++ b/apps/core/src/components/motions/scale.tsx @@ -0,0 +1,17 @@ +import type { ParentProps } from "solid-js" +import { Motion } from "solid-motionone" + +export interface ScaleProps extends ParentProps { + delay: number +} + +export default function Scale({ children, delay }: ScaleProps) { + return ( + + {children} + + ) +} diff --git a/apps/core/src/components/notifications/notifications-checker.tsx b/apps/core/src/components/notifications/notifications-checker.tsx new file mode 100644 index 0000000..8450ebf --- /dev/null +++ b/apps/core/src/components/notifications/notifications-checker.tsx @@ -0,0 +1,51 @@ +import { createEffect, untrack } from "solid-js" +import { + createBrightIdProfileQuery, + createInboundConnectionsQuery, + createOutboundConnectionsQuery, +} from "@/queries/connections" +import { authStore } from "@/store/auth" +import { ingestNotifications, notificationsStore } from "@/store/notifications" +import { diffNotifications } from "@aura/domain/notifications" + +/** + * Headless checker: whenever the session's profile/connections queries refresh + * (their normal cadence — no extra polling stack), diff the fresh data against + * the tracked snapshot and ingest any new alerts. Renders nothing. + */ +export default function NotificationsChecker() { + const subjectId = () => authStore.user?.brightId ?? "" + + const profile = createBrightIdProfileQuery(subjectId) + const inbound = createInboundConnectionsQuery(subjectId) + const outbound = createOutboundConnectionsQuery(subjectId) + + createEffect(() => { + const id = subjectId() + const verifications = profile.data?.verifications + const inboundData = inbound.data + const outboundData = outbound.data + if (!id || !verifications || !inboundData || !outboundData) return + + // untrack: the diff reads (and ingest writes) the notifications store — + // tracking those reads would re-trigger this effect in a loop. It should + // only re-run when the queries deliver fresh data. + untrack(() => { + const now = Date.now() + ingestNotifications( + diffNotifications({ + subjectId: id, + verifications, + inbound: inboundData, + outbound: outboundData, + prevTracked: notificationsStore.tracked, + lastFetch: notificationsStore.lastFetch, + now, + }), + now, + ) + }) + }) + + return null +} diff --git a/apps/core/src/components/search/global-search.tsx b/apps/core/src/components/search/global-search.tsx new file mode 100644 index 0000000..d8289d4 --- /dev/null +++ b/apps/core/src/components/search/global-search.tsx @@ -0,0 +1,144 @@ +import { useNavigate } from "@solidjs/router" +import { createMemo, createSignal, For, Show } from "solid-js" +import Avatar from "@/components/home/avatar" +import { useBackup, useNameResolver } from "@/hooks/use-backup" +import { useMyEvaluations } from "@/hooks/use-my-evaluations" +import type { DialogElement } from "@aura/ui" + +const MAX_RESULTS = 8 + +/** + * Global search — trigger button + dialog. Ported from the old + * `GlobalSearchModal`, upgraded from a bare "jump to home with ?search=" to + * live results: matching connections open `/subject/:id` directly; Enter + * falls through to the filtered home list (old behavior). + */ +export default function GlobalSearch() { + let dialog: DialogElement | undefined + const navigate = useNavigate() + + const [query, setQuery] = createSignal("") + + // Same source as the home list: backup connections when decrypted, + // node connections as the passkey-session fallback. + const backup = useBackup() + const { connections: nodeConnections } = useMyEvaluations() + const nameOf = useNameResolver() + + const candidates = createMemo(() => { + const conns = backup.data?.connections?.length + ? backup.data.connections + : (nodeConnections() ?? []) + return [...new Map(conns.map((c) => [c.id, c])).values()].map((c) => ({ + id: c.id, + name: nameOf(c.id), + })) + }) + + const results = createMemo(() => { + const q = query().trim().toLowerCase() + if (!q) return [] + return candidates() + .filter( + (c) => + c.name.toLowerCase().includes(q) || c.id.toLowerCase().includes(q), + ) + .slice(0, MAX_RESULTS) + }) + + const open = () => { + setQuery("") + dialog?.show() + } + + const goToSubject = (id: string) => { + dialog?.hide() + navigate(`/subject/${id}`) + } + + // Enter: single hit opens the subject, otherwise show the filtered list. + const submit = () => { + const q = query().trim() + if (!q) return + const hits = results() + if (hits.length === 1) return goToSubject(hits[0].id) + dialog?.hide() + navigate(`/home/player?search=${encodeURIComponent(q)}`) + } + + return ( + <> + + + + + +
+
+ + Global Search + + Search from your connections +
+ +
+ ) => setQuery(e.detail)} + onKeyDown={(e: KeyboardEvent) => e.key === "Enter" && submit()} + > + + + + Search + +
+ + 0}> +
+ + {(subject) => ( + + )} + +
+
+ + No connection matches. + +
+
+ + ) +} diff --git a/apps/core/src/components/settings/logout-button.tsx b/apps/core/src/components/settings/logout-button.tsx new file mode 100644 index 0000000..24470e0 --- /dev/null +++ b/apps/core/src/components/settings/logout-button.tsx @@ -0,0 +1,24 @@ +import { useNavigate } from "@solidjs/router" +import { clearQueryCache } from "@/providers" +import { logout } from "@/store/auth" +import { resetRecovery } from "@/store/recovery" + +export default function LogoutButton() { + const navigate = useNavigate() + return ( + { + logout() + resetRecovery() + // wipe cached node/backup data so nothing of the session lingers + void clearQueryCache() + navigate("/login", { replace: true }) + }} + > + Logout + + ) +} diff --git a/apps/core/src/components/settings/setting-card.tsx b/apps/core/src/components/settings/setting-card.tsx new file mode 100644 index 0000000..5e6c49a --- /dev/null +++ b/apps/core/src/components/settings/setting-card.tsx @@ -0,0 +1,49 @@ +import { A } from "@solidjs/router" +import type { JSX } from "solid-js" +import { Show } from "solid-js" + +/** A settings row card — either a link (href) or a button (onClick). */ +export default function SettingCard(props: { + icon?: string + label?: string + href?: string + external?: boolean + onClick?: () => void + testid?: string + children?: JSX.Element +}) { + const card = ( + + + + + + {props.label} + + {props.children} + + ) + + return ( + + + {card} + + } + > + + {card} + + + + ) +} diff --git a/apps/core/src/components/settings/theme-toggle.tsx b/apps/core/src/components/settings/theme-toggle.tsx new file mode 100644 index 0000000..a22e3c2 --- /dev/null +++ b/apps/core/src/components/settings/theme-toggle.tsx @@ -0,0 +1,18 @@ +import SettingCard from "@/components/settings/setting-card" +import { preferencesStore, setTheme } from "@/store/preferences" + +export default function ThemeToggle() { + const isDark = () => preferencesStore.theme === "dark" + return ( + setTheme(isDark() ? "light" : "dark")} + > + + {preferencesStore.theme.toUpperCase()} + + + ) +} diff --git a/apps/core/src/components/settings/version-card.tsx b/apps/core/src/components/settings/version-card.tsx new file mode 100644 index 0000000..c3010c3 --- /dev/null +++ b/apps/core/src/components/settings/version-card.tsx @@ -0,0 +1,31 @@ +import { Show } from "solid-js" +import { needRefresh, updateApp } from "@/shared/lib/pwa" + +/** + * App version card. When the service worker has a new build waiting (see + * `shared/lib/pwa.ts`), an Update button applies it — the old card's + * `/versioning.txt` check is replaced by the SW update signal. + */ +export default function VersionCard() { + return ( + +
+
+ + Aura +
+

+ You are using version {__APP_VERSION__} +

+
+ + + Update + + +
+ ) +} diff --git a/apps/core/src/components/shared/app-header.tsx b/apps/core/src/components/shared/app-header.tsx new file mode 100644 index 0000000..7794bd1 --- /dev/null +++ b/apps/core/src/components/shared/app-header.tsx @@ -0,0 +1,67 @@ +import { A, useLocation } from "@solidjs/router" +import { Show } from "solid-js" +import GlobalSearch from "@/components/search/global-search" +import NotificationBell from "@/components/shared/notification-bell" +import { authStore } from "@/store/auth" + +/** + * Persistent app header, ported from the old `DefaultHeader`: home link on the + * left, global search / notifications bell / settings on the right. Rendered + * from the root layout on every signed-in page except the landing flow + * (`/`, `/login`, `/onboarding`) and home, which has its own richer header. + */ +export default function AppHeader() { + const location = useLocation() + + const hidden = () => { + const path = location.pathname + return ( + path === "/" || + path.startsWith("/login") || + path.startsWith("/onboarding") || + path.startsWith("/home") + ) + } + + // Derive a page heading from the route so subpages regain the old header + // title (the old `DefaultHeader` always showed one). + const title = () => { + const path = location.pathname + if (path.startsWith("/subject")) return "Profile" + if (path.startsWith("/settings")) return "Settings" + if (path.startsWith("/notifications")) return "Notifications" + if (path.startsWith("/role-management")) return "Roles" + if (path.startsWith("/contact-info")) return "Contact info" + if (path.startsWith("/dashboard")) return "Dashboard" + if (path.startsWith("/domain-overview")) return "Domain" + return "" + } + + return ( + +
+
+ + + + + + + + {title()} + + +
+
+ + + + + + + +
+
+
+ ) +} diff --git a/apps/core/src/components/shared/level-score.tsx b/apps/core/src/components/shared/level-score.tsx new file mode 100644 index 0000000..b89987e --- /dev/null +++ b/apps/core/src/components/shared/level-score.tsx @@ -0,0 +1,32 @@ +import { Show } from "solid-js" +import { compactFormat } from "@/shared/lib/number" + +/** The "Level: X Score: Y" line used by profile and subject cards. */ +export default function LevelScore(props: { + level: number | null + score: number | null + testid?: string +}) { + return ( +
+ Level:{" "} + + {props.level ?? "-"} + + + Score:{" "} + + + {compactFormat(props.score!)} + + + +
+ ) +} diff --git a/apps/core/src/components/shared/notification-bell.tsx b/apps/core/src/components/shared/notification-bell.tsx new file mode 100644 index 0000000..85ca617 --- /dev/null +++ b/apps/core/src/components/shared/notification-bell.tsx @@ -0,0 +1,19 @@ +import { A } from "@solidjs/router" +import { Show } from "solid-js" +import { unreadCount } from "@/store/notifications" + +/** Bell link to `/notifications` with the unread-count badge. */ +export default function NotificationBell() { + return ( + + + + + 0}> + + {unreadCount()} + + + + ) +} diff --git a/apps/core/src/components/shared/skeleton.tsx b/apps/core/src/components/shared/skeleton.tsx new file mode 100644 index 0000000..6c43d3a --- /dev/null +++ b/apps/core/src/components/shared/skeleton.tsx @@ -0,0 +1,18 @@ +import type { JSX } from "solid-js" + +/** + * Pulsing placeholder block for loading states. Size/shape it with `class` + * (`h-*`, `w-*`, `rounded-*`) — mirrors the shimmer used in the charts. + */ +export default function Skeleton(props: { + class?: string + style?: JSX.CSSProperties +}) { + return ( +