@exxatdesignux/ui 0.2.16 → 0.2.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +149 -4
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
  4. package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
  6. package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
  7. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +19 -0
  9. package/consumer-extras/patterns/data-views-pattern.md +2 -0
  10. package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
  11. package/consumer-extras/patterns/shell-surface-elevation-pattern.md +52 -0
  12. package/package.json +3 -3
  13. package/src/components/ui/banner.tsx +2 -0
  14. package/src/components/ui/chart.tsx +57 -2
  15. package/src/components/ui/sidebar.tsx +3 -2
  16. package/src/globals.css +65 -14
  17. package/src/theme.css +3 -3
  18. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  19. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
  20. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  21. package/template/AGENTS.md +27 -17
  22. package/template/app/(app)/data-list/page.tsx +2 -2
  23. package/template/app/(app)/error.tsx +22 -6
  24. package/template/app/(app)/layout.tsx +13 -6
  25. package/template/app/(app)/question-bank/layout.tsx +18 -5
  26. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  27. package/template/app/global-error.tsx +63 -0
  28. package/template/app/globals.css +151 -14
  29. package/template/app/layout.tsx +43 -5
  30. package/template/components/app-sidebar.tsx +68 -33
  31. package/template/components/ask-leo-sidebar.tsx +0 -2
  32. package/template/components/brand-color-picker.tsx +344 -0
  33. package/template/components/compliance-list-view.tsx +33 -51
  34. package/template/components/compliance-table.tsx +4 -0
  35. package/template/components/data-table/index.tsx +99 -91
  36. package/template/components/data-table/pagination.tsx +0 -1
  37. package/template/components/data-table/types.ts +4 -1
  38. package/template/components/data-table/use-table-state.ts +276 -100
  39. package/template/components/data-views/data-row-list.tsx +183 -0
  40. package/template/components/data-views/index.ts +7 -3
  41. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  42. package/template/components/dev-chunk-load-recovery.tsx +41 -0
  43. package/template/components/export-drawer.tsx +1 -1
  44. package/template/components/exxat-product-logo.tsx +168 -317
  45. package/template/components/invite-collaborators-drawer.tsx +5 -3
  46. package/template/components/key-metrics.tsx +122 -62
  47. package/template/components/new-placement-form.tsx +4 -2
  48. package/template/components/new-question-composer.tsx +2208 -0
  49. package/template/components/page-breadcrumb-trail.tsx +131 -0
  50. package/template/components/page-header.tsx +2 -1
  51. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +2 -2
  52. package/template/components/placement-detail.tsx +1 -1
  53. package/template/components/placements-board-view.tsx +1 -1
  54. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  55. package/template/components/placements-list-view.tsx +19 -133
  56. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  57. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  58. package/template/components/placements-table-columns.tsx +2 -2
  59. package/template/components/{data-list-table.tsx → placements-table.tsx} +42 -66
  60. package/template/components/product-switcher.tsx +24 -7
  61. package/template/components/product-wordmark.tsx +282 -0
  62. package/template/components/question-bank-client.tsx +20 -2
  63. package/template/components/question-bank-hub-client.tsx +105 -115
  64. package/template/components/question-bank-list-view.tsx +30 -54
  65. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  66. package/template/components/question-bank-secondary-nav.tsx +0 -3
  67. package/template/components/question-bank-table.tsx +19 -6
  68. package/template/components/rotations-empty-state.tsx +3 -0
  69. package/template/components/secondary-panel.tsx +23 -3
  70. package/template/components/settings-appearance-card.tsx +584 -141
  71. package/template/components/sidebar-shell.tsx +2 -1
  72. package/template/components/site-header.tsx +36 -31
  73. package/template/components/sites-list-view.tsx +31 -36
  74. package/template/components/sites-table.tsx +4 -0
  75. package/template/components/table-properties/drawer-button.tsx +38 -20
  76. package/template/components/table-properties/drawer.tsx +17 -14
  77. package/template/components/team-client.tsx +1 -1
  78. package/template/components/team-list-view.tsx +34 -50
  79. package/template/components/team-table.tsx +8 -3
  80. package/template/components/templates/list-page.tsx +12 -9
  81. package/template/components/templates/nested-secondary-panel-shell.tsx +10 -4
  82. package/template/components/ui/dot-pattern.tsx +50 -26
  83. package/template/components/ui/leo-icon.tsx +23 -3
  84. package/template/contexts/product-context.tsx +70 -7
  85. package/template/contexts/system-banner-context.tsx +112 -4
  86. package/template/docs/data-views-pattern.md +2 -0
  87. package/template/docs/kpi-flat-band-pattern.md +57 -0
  88. package/template/docs/kpi-strip-max-four-pattern.md +1 -0
  89. package/template/docs/shell-surface-elevation-pattern.md +52 -0
  90. package/template/eslint.config.mjs +18 -0
  91. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  92. package/template/lib/chunk-load-error.ts +13 -0
  93. package/template/lib/conditional-rule-match.ts +87 -22
  94. package/template/lib/data-list-persistence.ts +57 -257
  95. package/template/lib/data-list-view.ts +6 -0
  96. package/template/lib/dev-log.test.ts +6 -5
  97. package/template/lib/exxat-palette.json +1462 -0
  98. package/template/lib/exxat-palette.ts +136 -0
  99. package/template/lib/list-page-table-properties.ts +1 -1
  100. package/template/lib/list-status-badges.ts +1 -1
  101. package/template/lib/mailto.ts +29 -0
  102. package/template/lib/placement-board-card-layout.ts +1 -1
  103. package/template/lib/product-brand.ts +268 -0
  104. package/template/lib/question-bank-authoring.ts +308 -0
  105. package/template/lib/question-bank-nav.ts +44 -0
  106. package/template/lib/raf-throttle.ts +45 -0
  107. package/template/lib/sidebar-state-cookie.ts +9 -0
  108. package/template/lib/table-state-lifecycle.ts +521 -0
  109. package/template/next.config.mjs +156 -0
  110. package/template/package.json +3 -3
  111. package/template/stores/app-store.ts +46 -1
@@ -198,23 +198,88 @@ export interface KeyMetricsProps {
198
198
  className?: string
199
199
  }
200
200
 
201
- /** Wrap KPI columns when the strip is narrow (high zoom, 5+ tiles) instead of squeezing cells. */
202
- const METRICS_GRID_TEMPLATE =
203
- "repeat(auto-fit, minmax(min(100%, 11.5rem), 1fr))"
204
-
205
- /** Equal columns in one row up to 4 KPIs beside an insight rail without premature wrap. */
206
- function metricsRowColumns(
207
- rowLength: number,
208
- metricsSingleRow: boolean,
201
+ /**
202
+ * KPI grid column step patterns — Tailwind v4 container-query classes.
203
+ *
204
+ * We deliberately AVOID `repeat(auto-fit, minmax(...))` here because it
205
+ * produces awkward "N + leftover" layouts at intermediate widths (e.g. 3
206
+ * tiles in row 1 + 1 lonely tile in row 2 for a 4-KPI strip). Instead we
207
+ * step the column count through values that evenly divide the row size:
208
+ * 1 → 2 → 4 for a 4-KPI strip (3 is skipped on purpose).
209
+ *
210
+ * The breakpoints are container-query based (`@[Xrem]:…`) so they react to
211
+ * the metrics strip's OWN width, not the viewport — that's what makes the
212
+ * 2×2 fallback kick in when the primary sidebar + secondary panel are
213
+ * both open and the strip column is ~360 px wide, even on a 1280 px display.
214
+ *
215
+ * `metricsHalfWidthLayout` = strip shares its row with the insight rail
216
+ * (3fr / 2fr split). Tighter breakpoints because available width is ~60%
217
+ * of the section.
218
+ */
219
+ /**
220
+ * Flat KPI hairlines — cell borders only (no grid gap fill / no surface).
221
+ * Four tiles: default 4-across verticals; 2×2 hairlines only when @container is narrow.
222
+ */
223
+ function flatMetricsHairlineClass(
224
+ itemCount: number,
209
225
  metricsHalfWidthLayout: boolean,
210
226
  ): string {
211
- if (metricsHalfWidthLayout) {
212
- return `repeat(${rowLength}, minmax(0, 1fr))`
227
+ if (itemCount <= 1) return "gap-0"
228
+
229
+ const childBorder = "[&>*]:border-[color:var(--key-metrics-flat-divider)]"
230
+
231
+ if (itemCount === 2) {
232
+ return cn("gap-0", childBorder, "[&>*:first-child]:border-r")
233
+ }
234
+
235
+ if (itemCount === 4) {
236
+ const narrow2x2 = metricsHalfWidthLayout
237
+ ? "@[max-width:25.99rem]"
238
+ : "@[max-width:29.99rem]"
239
+ return cn(
240
+ "gap-0",
241
+ childBorder,
242
+ /* Wide strip (matches `@[30rem]:grid-cols-4`) — verticals between all tiles, no horizontal */
243
+ "[&>*:not(:last-child)]:border-r",
244
+ /* Narrow strip (`@[18rem]`–`@[30rem]` 2×2) */
245
+ `${narrow2x2}:[&>*:not(:last-child)]:border-r-0`,
246
+ `${narrow2x2}:[&>*:nth-child(odd)]:border-r`,
247
+ `${narrow2x2}:[&>*:not(:nth-last-child(-n+2))]:border-b`,
248
+ )
213
249
  }
214
- if (metricsSingleRow) {
215
- return rowLength > 4 ? METRICS_GRID_TEMPLATE : `repeat(${rowLength}, minmax(0, 1fr))`
250
+
251
+ return cn("gap-0", childBorder, "[&>*:not(:last-child)]:border-r")
252
+ }
253
+
254
+ function metricsRowColumnsClass(rowLength: number, metricsHalfWidthLayout: boolean): string {
255
+ const half = metricsHalfWidthLayout
256
+ switch (rowLength) {
257
+ case 1:
258
+ return "grid-cols-1"
259
+ case 2:
260
+ return half
261
+ ? "grid-cols-1 @[14rem]:grid-cols-2"
262
+ : "grid-cols-1 @[18rem]:grid-cols-2"
263
+ case 3:
264
+ // 3 tiles divide evenly already — step 1 → 3.
265
+ return half
266
+ ? "grid-cols-1 @[18rem]:grid-cols-3"
267
+ : "grid-cols-1 @[24rem]:grid-cols-3"
268
+ case 4:
269
+ // Step 1 → 2 (2×2 grid) → 4. Skip 3 — that's the awkward 3+1 layout.
270
+ // Aggressive 4-col thresholds so the strip fits all four tiles even
271
+ // when the primary sidebar + secondary panel + insight rail are all
272
+ // expanded (typical question-bank layout puts the KPI grid at ~27rem).
273
+ return half
274
+ ? "grid-cols-1 @[14rem]:grid-cols-2 @[26rem]:grid-cols-4"
275
+ : "grid-cols-1 @[18rem]:grid-cols-2 @[30rem]:grid-cols-4"
276
+ default:
277
+ // 5+ KPIs (`exxat-kpi-max-four` caps the strip at 4, but key-metrics
278
+ // is a generic primitive — fall back to a sensible step). 1 → 2 → 3 → 6.
279
+ return half
280
+ ? "grid-cols-1 @[14rem]:grid-cols-2 @[26rem]:grid-cols-3 @[40rem]:grid-cols-6"
281
+ : "grid-cols-1 @[18rem]:grid-cols-2 @[30rem]:grid-cols-3 @[56rem]:grid-cols-6"
216
282
  }
217
- return `repeat(${rowLength}, minmax(0, 1fr))`
218
283
  }
219
284
 
220
285
  /* ── Default data ─────────────────────────────────────────────────────────── */
@@ -507,6 +572,9 @@ function KeyMetricsInner({
507
572
  surfaceVariant = "default",
508
573
  }: InnerProps) {
509
574
  const isFlatBand = surfaceVariant === "flat"
575
+ const metricsGridClassName = isFlatBand
576
+ ? flatMetricsHairlineClass(metrics.length, metricsHalfWidthLayout)
577
+ : "gap-px bg-border"
510
578
  /** Side-by-side KPI + insight rail (md+). Disabled for half-width dashboard cards — insight stacks below. */
511
579
  const insightSideBySide = insight && !insightFullWidth && !metricsHalfWidthLayout
512
580
  const stackedRailInsight = insight && !insightFullWidth && metricsHalfWidthLayout
@@ -571,31 +639,25 @@ function KeyMetricsInner({
571
639
  {metricsHalfWidthLayout ? (
572
640
  <div
573
641
  className={cn(
574
- "grid grid-cols-2 divide-x lg:hidden",
575
- isFlatBand ? "divide-border/40" : "divide-border",
576
- )}
577
- style={
642
+ "@container/metrics-strip grid lg:hidden",
578
643
  metricsSingleRow
579
- ? {
580
- gridTemplateColumns: metricsRowColumns(
581
- metrics.length,
582
- metricsSingleRow,
583
- metricsHalfWidthLayout,
584
- ),
585
- }
586
- : undefined
587
- }
644
+ ? metricsRowColumnsClass(metrics.length, /* half */ true)
645
+ : "grid-cols-2",
646
+ metricsGridClassName,
647
+ )}
588
648
  >
589
649
  {metrics.map((m) => (
590
- <MetricCell key={m.id} {...m} dense />
650
+ <div key={m.id} className={cn("min-w-0", metricsCellSurfaceClassName)}>
651
+ <MetricCell {...m} dense edgeGutter={false} />
652
+ </div>
591
653
  ))}
592
654
  </div>
593
655
  ) : (
594
656
  <div
595
657
  className={cn(
596
- "grid gap-px lg:hidden",
597
- "grid-cols-1 md:grid-cols-2",
598
- isFlatBand ? "bg-foreground/[0.04]" : "bg-border",
658
+ "@container/metrics-strip grid lg:hidden",
659
+ metricsRowColumnsClass(metrics.length, /* half */ false),
660
+ metricsGridClassName,
599
661
  )}
600
662
  >
601
663
  {metrics.map((m) => (
@@ -606,31 +668,31 @@ function KeyMetricsInner({
606
668
  </div>
607
669
  )}
608
670
 
609
- {/* lg+: row-by-row 3-col with horizontal separator between rows */}
610
- <div className="hidden lg:block">
671
+ {/*
672
+ lg+: row-by-row container-queried grid. Uses a `gap-px + bg` hairline
673
+ instead of `divide-x` so dividers render correctly when the row wraps
674
+ from 4-across to a 2×2 grid (the awkward 3+1 layout is skipped — see
675
+ `metricsRowColumnsClass`).
676
+ */}
677
+ <div className="@container/metrics-strip hidden lg:block">
611
678
  {rows.map((row, rowIdx) => (
612
679
  <React.Fragment key={rowIdx}>
613
- {rowIdx > 0 && (
614
- <Separator
615
- aria-hidden="true"
616
- className={cn("my-1", isFlatBand && "bg-foreground/[0.055]")}
617
- />
680
+ {rowIdx > 0 && !isFlatBand && (
681
+ <Separator aria-hidden="true" className="my-1" />
618
682
  )}
619
683
  <div
620
684
  className={cn(
621
- "grid divide-x",
622
- isFlatBand ? "divide-border/40" : "divide-border",
685
+ "grid",
686
+ metricsRowColumnsClass(row.length, metricsHalfWidthLayout),
687
+ isFlatBand
688
+ ? flatMetricsHairlineClass(row.length, metricsHalfWidthLayout)
689
+ : metricsGridClassName,
623
690
  )}
624
- style={{
625
- gridTemplateColumns: metricsRowColumns(
626
- row.length,
627
- metricsSingleRow,
628
- metricsHalfWidthLayout,
629
- ),
630
- }}
631
691
  >
632
692
  {row.map((m) => (
633
- <MetricCell key={m.id} {...m} dense={metricsHalfWidthLayout} />
693
+ <div key={m.id} className={cn("min-w-0", metricsCellSurfaceClassName)}>
694
+ <MetricCell {...m} dense={metricsHalfWidthLayout} edgeGutter={false} />
695
+ </div>
634
696
  ))}
635
697
  </div>
636
698
  </React.Fragment>
@@ -665,8 +727,9 @@ function KeyMetricsInner({
665
727
  insightSideBySide &&
666
728
  !insightFullWidth &&
667
729
  cn(
668
- "lg:h-full lg:border-l lg:pl-6",
669
- isFlatBand ? "lg:border-border/40" : "lg:border-border",
730
+ "lg:h-full lg:pl-6",
731
+ /* Flat band: insight card ring is the divider — skip `border-l` (double line). */
732
+ !isFlatBand && "lg:border-l lg:border-border",
670
733
  )
671
734
  )}
672
735
  >
@@ -828,7 +891,9 @@ export function KeyMetrics({
828
891
  })()
829
892
 
830
893
  const metricsCellSurfaceClassName =
831
- variant === "flat" ? "bg-[var(--key-metrics-flat-cell-bg)]" : "bg-card"
894
+ variant === "flat"
895
+ ? "bg-transparent"
896
+ : "bg-card dark:bg-transparent"
832
897
 
833
898
  const innerProps: InnerProps = {
834
899
  title,
@@ -872,18 +937,13 @@ export function KeyMetrics({
872
937
  * ─────────────────────────────────────────────────────────────────────────
873
938
  */
874
939
  const glowStyle: React.CSSProperties = {
875
- /* oklch relative color: inherit brand hue/chroma/lightness, set alpha only */
876
- background:
877
- "radial-gradient(ellipse 110% 90% at 50% 100%, oklch(from var(--brand-color) l c h / 0.13) 0%, transparent 65%)",
940
+ background: "var(--key-metrics-card-glow-radial)",
878
941
  }
879
942
 
880
- /** List-page KPI band: soft tint page bg + gentle lift (avoids a hard line into the toolbar). */
943
+ /** List-page KPI band transparent; only `--key-metrics-flat-band-radial` glow. */
881
944
  const flatBandStyle: React.CSSProperties = {
882
- background: [
883
- "radial-gradient(ellipse 118% 70% at 50% 100%, oklch(from var(--brand-color) l c h / 0.11) 0%, transparent 60%)",
884
- "linear-gradient(180deg, var(--key-metrics-flat-grad-top) 0%, var(--key-metrics-flat-grad-mid) 48%, var(--background) 100%)",
885
- ].join(", "),
886
- boxShadow: "0 18px 42px -26px color-mix(in oklch, var(--brand-color) 10%, transparent)",
945
+ background: "var(--key-metrics-flat-band-radial)",
946
+ boxShadow: "var(--key-metrics-flat-band-shadow)",
887
947
  }
888
948
 
889
949
  /* ── Card variant — ChartCard-style chrome ───────────────────────────── */
@@ -954,11 +1014,11 @@ export function KeyMetrics({
954
1014
  )
955
1015
  }
956
1016
 
957
- /* ── Flat variant — soft tint band + bottom glow (no sharp cut to content below) ── */
1017
+ /* ── Flat variant — no surface; bottom brand glow only ── */
958
1018
  return (
959
1019
  <section
960
1020
  aria-label={title}
961
- className={cn("relative w-full overflow-hidden pt-5 pb-6", className)}
1021
+ className={cn("relative w-full overflow-hidden pt-5 pb-8", className)}
962
1022
  style={flatBandStyle}
963
1023
  >
964
1024
  <KeyMetricsInner
@@ -997,7 +1057,7 @@ export function KeyMetricsContent({
997
1057
  showHeader={false}
998
1058
  insightCompact={insightCompact}
999
1059
  insightFullWidth={insightFullWidth}
1000
- metricsCellSurfaceClassName="bg-card"
1060
+ metricsCellSurfaceClassName="bg-card dark:bg-transparent"
1001
1061
  />
1002
1062
  )
1003
1063
  }
@@ -25,6 +25,7 @@ import { useRouter } from "next/navigation"
25
25
  import {
26
26
  useForm,
27
27
  useFormContext,
28
+ useWatch,
28
29
  type ControllerRenderProps,
29
30
  type Resolver,
30
31
  } from "react-hook-form"
@@ -64,7 +65,6 @@ import {
64
65
  import { RadioGroup, RadioGroupItem, RadioGroupLabel } from "@/components/ui/radio-group"
65
66
  import { Card, CardHeader, CardTitle, CardAction, CardContent } from "@/components/ui/card"
66
67
  import { Kbd, KbdGroup } from "@/components/ui/kbd"
67
- import { Tip } from "@/components/ui/tip"
68
68
  import { Shortcut } from "@/components/ui/dropdown-menu"
69
69
  import { useModKeyLabel, useAltKeyLabel } from "@/hooks/use-mod-key-label"
70
70
 
@@ -967,7 +967,9 @@ export function NewPlacementForm() {
967
967
  router.push("/data-list")
968
968
  }
969
969
 
970
- const formData = form.watch()
970
+ // `useWatch` is memoization-friendly (returns a stable reactive value)
971
+ // unlike `form.watch()`, which the React Compiler can't memoize safely.
972
+ const formData = useWatch({ control: form.control })
971
973
  const mod = useModKeyLabel()
972
974
  const alt = useAltKeyLabel()
973
975