@cosmicdrift/kumiko-samples 0.391.1 → 0.392.0

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 (137) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/admin-shell/changes.json +6 -0
  4. package/packages/bundled-features/src/admin-shell/feature.ts +60 -31
  5. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -2
  6. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -2
  7. package/packages/bundled-features/src/cap-overview/changes.json +12 -0
  8. package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +1 -0
  9. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +3 -2
  10. package/packages/bundled-features/src/cap-overview/i18n.ts +4 -0
  11. package/packages/bundled-features/src/cap-overview/types.ts +4 -1
  12. package/packages/bundled-features/src/cap-overview/web/cap-amount-format.ts +37 -0
  13. package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +138 -54
  14. package/packages/bundled-features/src/cap-overview/web/cap-usage-bar.tsx +18 -4
  15. package/packages/bundled-features/src/cap-overview/web/cap-usage-cell.tsx +6 -3
  16. package/packages/bundled-features/src/cap-overview/web/usage-math.ts +5 -1
  17. package/packages/bundled-features/src/channel-email/app-transport.ts +37 -0
  18. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  19. package/packages/bundled-features/src/channel-email/feature.ts +6 -0
  20. package/packages/bundled-features/src/channel-email/index.ts +5 -0
  21. package/packages/bundled-features/src/channel-email/smtp-transport.ts +2 -22
  22. package/packages/bundled-features/src/foundation-shared/changes.json +6 -0
  23. package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
  24. package/packages/bundled-features/src/foundation-shared/mail-display-name.ts +20 -0
  25. package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +48 -0
  26. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
  27. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
  28. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +17 -0
  29. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +18 -0
  30. package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +66 -0
  31. package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +8 -45
  32. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +14 -0
  33. package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +118 -0
  34. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +8 -0
  35. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +13 -8
  36. package/packages/bundled-features/src/inbound-provider-imap/changes.json +12 -0
  37. package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +156 -0
  38. package/packages/bundled-features/src/inbound-provider-imap/constants.ts +42 -0
  39. package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +49 -1
  40. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +12 -35
  41. package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +48 -0
  42. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +1 -1
  43. package/packages/bundled-features/src/inbound-provider-imap/screens.ts +44 -0
  44. package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +73 -0
  45. package/packages/bundled-features/src/inbound-provider-imap/sent-folder.ts +49 -0
  46. package/packages/bundled-features/src/inbound-provider-imap/smtp-client.ts +131 -0
  47. package/packages/bundled-features/src/legal-pages/README.md +5 -10
  48. package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
  49. package/packages/bundled-features/src/mail-foundation/i18n.ts +9 -1
  50. package/packages/bundled-features/src/mail-foundation/probe.ts +43 -22
  51. package/packages/bundled-features/src/readiness/built-in-probes.ts +15 -2
  52. package/packages/bundled-features/src/readiness/changes.json +30 -0
  53. package/packages/bundled-features/src/readiness/constants.ts +2 -0
  54. package/packages/bundled-features/src/readiness/i18n.ts +7 -1
  55. package/packages/bundled-features/src/readiness/index.ts +6 -1
  56. package/packages/bundled-features/src/readiness/screens.ts +25 -2
  57. package/packages/bundled-features/src/readiness/visible-results.ts +2 -1
  58. package/packages/bundled-features/src/renderer-foundation/README.md +19 -21
  59. package/packages/bundled-features/src/template-resolver/README.md +144 -165
  60. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  61. package/packages/bundled-features/src/tier-engine/feature.ts +17 -6
  62. package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +3 -3
  63. package/packages/bundled-features/src/user-data-rights/schema/download-token.ts +5 -3
  64. package/packages/bundled-features/src/user-data-rights/token-helpers.ts +5 -4
  65. package/samples/apps/cap-billing-demo/bin/server.ts +7 -7
  66. package/samples/apps/cap-billing-demo/src/feature.ts +38 -38
  67. package/samples/apps/cap-billing-demo/src/run-config.ts +20 -20
  68. package/samples/apps/cap-billing-demo/src/tier-map.ts +14 -14
  69. package/samples/apps/config-demo/src/app/server.ts +4 -4
  70. package/samples/apps/config-demo/src/features/demo/feature.ts +4 -4
  71. package/samples/apps/marketing-demo/playwright.config.ts +3 -3
  72. package/samples/apps/marketing-demo/src/app/client.tsx +2 -2
  73. package/samples/apps/marketing-demo/src/app/seed-data.ts +9 -9
  74. package/samples/apps/marketing-demo/src/app/seed.ts +14 -14
  75. package/samples/apps/marketing-demo/src/app/server.ts +7 -7
  76. package/samples/apps/marketing-demo/src/app/shell.tsx +3 -3
  77. package/samples/apps/marketing-demo/src/features/assets/feature.ts +3 -3
  78. package/samples/apps/marketing-demo/src/features/assets/i18n.ts +7 -7
  79. package/samples/apps/marketing-demo/src/features/assets/web/index.ts +2 -2
  80. package/samples/apps/showcase/src/app/client.tsx +3 -3
  81. package/samples/apps/showcase/src/app/seed-items.ts +14 -14
  82. package/samples/apps/showcase/src/app/server.ts +7 -7
  83. package/samples/apps/showcase/src/features/demos/feature.ts +13 -13
  84. package/samples/apps/showcase/src/features/demos/i18n.ts +2 -2
  85. package/samples/apps/showcase/src/features/demos/web/components/page.tsx +5 -5
  86. package/samples/apps/showcase/src/features/demos/web/index.ts +4 -4
  87. package/samples/apps/showcase/src/features/demos/web/pages/dialog.tsx +1 -1
  88. package/samples/apps/showcase/src/features/demos/web/pages/inputs.tsx +8 -8
  89. package/samples/apps/showcase/src/features/demos/web/pages/layout.tsx +5 -5
  90. package/samples/apps/showcase/src/features/demos/web/pages/toast.tsx +3 -3
  91. package/samples/apps/showcase/src/features/items/feature.ts +8 -8
  92. package/samples/apps/showcase/src/features/items/i18n.ts +3 -3
  93. package/samples/apps/showcase/src/features/items/index.ts +3 -3
  94. package/samples/apps/showcase/src/features/items/schema/index.ts +4 -4
  95. package/samples/apps/showcase/src/features/items/schema/item.ts +27 -27
  96. package/samples/apps/showcase/src/features/items/web/index.ts +6 -6
  97. package/samples/apps/styleguide/src/app/seed.ts +3 -3
  98. package/samples/apps/styleguide/src/app/server.ts +2 -2
  99. package/samples/apps/styleguide/src/app/shell.tsx +4 -4
  100. package/samples/apps/styleguide/src/features/content/web.tsx +6 -6
  101. package/samples/apps/styleguide/src/features/examples/feature.ts +8 -8
  102. package/samples/apps/styleguide/src/features/gallery/feature.ts +9 -9
  103. package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +12 -12
  104. package/samples/apps/styleguide/src/features/widgets/feature.ts +2 -2
  105. package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +3 -3
  106. package/samples/apps/styleguide/src/features/widgets/web/DashboardKpiIcon.tsx +4 -4
  107. package/samples/apps/ui-walkthrough/playwright.config.ts +6 -6
  108. package/samples/apps/ui-walkthrough/scripts/emit-e2e-data.ts +5 -5
  109. package/samples/apps/ui-walkthrough/src/app/auth-constants.ts +13 -13
  110. package/samples/apps/ui-walkthrough/src/app/client.tsx +2 -2
  111. package/samples/apps/ui-walkthrough/src/app/server.ts +14 -14
  112. package/samples/apps/ui-walkthrough/src/app/shell.tsx +3 -3
  113. package/samples/apps/use-all-bundled/bin/main.ts +5 -5
  114. package/samples/apps/use-all-bundled/feature-manifest.json +10 -4
  115. package/samples/apps/use-all-bundled/kumiko/schema.ts +7 -7
  116. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +20 -20
  117. package/samples/apps/use-all-bundled/src/app/auth-constants.ts +4 -4
  118. package/samples/apps/use-all-bundled/src/app/client.tsx +4 -4
  119. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +12 -12
  120. package/samples/apps/use-all-bundled/src/app/seed.ts +6 -6
  121. package/samples/apps/user-data-rights-demo/src/feature.ts +23 -23
  122. package/samples/apps/workspaces/playwright.config.ts +5 -5
  123. package/samples/apps/workspaces/src/app/auth-constants.ts +3 -3
  124. package/samples/apps/workspaces/src/app/server.ts +16 -16
  125. package/samples/apps/workspaces/src/app/shell.tsx +2 -2
  126. package/samples/apps/workspaces/src/features/demo/schema/order.ts +4 -4
  127. package/samples/apps/workspaces/src/features/demo-driver/feature.ts +5 -5
  128. package/samples/recipes/apex-surface-auth/src/feature.ts +31 -31
  129. package/samples/recipes/designer-demo/src/feature.ts +6 -6
  130. package/samples/recipes/encrypted-tenant-config/src/feature.ts +20 -20
  131. package/samples/recipes/lane-routing/src/feature.ts +5 -5
  132. package/samples/recipes/legal-pages/src/feature.ts +29 -29
  133. package/samples/recipes/record-detail-layout/src/feature.ts +21 -2
  134. package/samples/recipes/seed-migration/scripts/smoke.ts +20 -20
  135. package/samples/recipes/seed-migration/seeds/2026-01-01-initial-tenants.ts +11 -11
  136. package/samples/recipes/seed-migration/seeds/2026-02-01-fix-admin-roles.ts +13 -13
  137. package/samples/recipes/user-data-rights/src/feature.ts +13 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.391.1",
3
+ "version": "0.392.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.391.1",
3
+ "version": "0.392.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.392.0",
4
+ "type": "improvement",
5
+ "title": "includeTenantOverview turns the tenant overview off",
6
+ "detail": "createAdminShellFeature({ includeTenantOverview: false }) registers neither the tenant overview screen nor its nav and leaves it out of the tenant workspace. The default stays true."
7
+ },
2
8
  {
3
9
  "version": "0.363.0",
4
10
  "type": "improvement",
@@ -39,6 +39,9 @@ export type CreateAdminShellOptions = {
39
39
  /** Tenant nav → document-designer:nav:templates, platform nav → document-designer:nav:platform-templates.
40
40
  * Requires document-designer mounted (a separate package, so no import here). Default false. */
41
41
  readonly includeDocumentDesigner?: boolean;
42
+ /** Tenant overview dashboard: screen, nav and the entry in the tenant workspace. Default true.
43
+ * Set false when the app ships its own landing screen for tenant admins. */
44
+ readonly includeTenantOverview?: boolean;
42
45
  /** When false, only overview screens + nav are registered — app owns r.workspace(). Default true. */
43
46
  readonly registerWorkspaces?: boolean;
44
47
  /** Metric list the overview panels are built from — pass the SAME list to
@@ -47,18 +50,48 @@ export type CreateAdminShellOptions = {
47
50
  readonly metrics?: readonly MetricDefinition[];
48
51
  };
49
52
 
53
+ type NavIdOptions = {
54
+ readonly includeTenantOverview?: boolean;
55
+ readonly includeTierAdmin?: boolean;
56
+ readonly includeCapOverview: boolean;
57
+ readonly includeDocumentDesigner: boolean;
58
+ };
59
+
60
+ function tenantWorkspaceNavIds(options: NavIdOptions): string[] {
61
+ return [
62
+ ...(options.includeTenantOverview === true ? ["admin-shell:nav:tenant-overview"] : []),
63
+ "tenant:nav:members",
64
+ "audit:nav:audit-log",
65
+ ...(options.includeCapOverview ? ["admin-shell:nav:my-caps"] : []),
66
+ ...(options.includeDocumentDesigner ? ["document-designer:nav:templates"] : []),
67
+ ];
68
+ }
69
+
70
+ function platformWorkspaceNavIds(options: NavIdOptions): string[] {
71
+ return [
72
+ "admin-shell:nav:platform-overview",
73
+ "admin-shell:nav:tenants",
74
+ ...(options.includeCapOverview ? ["admin-shell:nav:tenant-caps"] : []),
75
+ "audit:nav:audit-log-all-tenants",
76
+ "jobs:nav:job-runs",
77
+ ...(options.includeDocumentDesigner ? ["document-designer:nav:platform-templates"] : []),
78
+ ...(options.includeTierAdmin === true ? ["admin-shell:nav:tier-admin"] : []),
79
+ ];
80
+ }
81
+
50
82
  export function createAdminShellFeature(options: CreateAdminShellOptions = {}): FeatureDefinition {
51
83
  const tenantWsId = options.workspaceIds?.tenant ?? DEFAULT_TENANT_WORKSPACE_ID;
52
84
  const platformWsId = options.workspaceIds?.platform ?? DEFAULT_PLATFORM_WORKSPACE_ID;
53
85
  const includeTierAdmin = options.includeTierAdmin ?? true;
54
86
  const includeCapOverview = options.includeCapOverview ?? false;
55
87
  const includeDocumentDesigner = options.includeDocumentDesigner ?? false;
88
+ const includeTenantOverview = options.includeTenantOverview ?? true;
56
89
  const registerWorkspaces = options.registerWorkspaces ?? true;
57
90
  const metrics = options.metrics ?? DEFAULT_METRICS;
58
91
 
59
92
  return defineFeature(ADMIN_SHELL_FEATURE, (r) => {
60
93
  r.describe(
61
- "Registers tenant-admin and platform-admin workspaces with provider nav into owner-feature screens (`tenant:screen:members`, `audit:screen:audit-log`, `audit:screen:audit-log-all-tenants`, `tenant:screen:tenant-list`, `jobs:screen:job-runs`, optional `tier-engine:screen:tier-admin`, optional `cap-overview:screen:my-caps`/`cap-overview:screen:tenant-cap-list`, optional `document-designer` template navs) and the two overview dashboards, built from the metrics list (`metrics` option, default DEFAULT_METRICS). Mount after user, tenant, audit, jobs, metrics and metrics-system; pass `workspaceIds` to match app URL conventions (e.g. Studio `d`/`s`, PublicStatus `admin`/`sysadmin`). Client: `adminShellClient()`, `tenantClient()`, `auditClient()`, `jobsClient()`, optional `tierEngineClient()`.",
94
+ "Registers tenant-admin and platform-admin workspaces with provider nav into owner-feature screens (`tenant:screen:members`, `audit:screen:audit-log`, `audit:screen:audit-log-all-tenants`, `tenant:screen:tenant-list`, `jobs:screen:job-runs`, optional `tier-engine:screen:tier-admin`, optional `cap-overview:screen:my-caps`/`cap-overview:screen:tenant-cap-list`, optional `document-designer` template navs) and the two overview dashboards (the tenant one can be dropped with `includeTenantOverview: false`), built from the metrics list (`metrics` option, default DEFAULT_METRICS). Mount after user, tenant, audit, jobs, metrics and metrics-system; pass `workspaceIds` to match app URL conventions (e.g. Studio `d`/`s`, PublicStatus `admin`/`sysadmin`). Client: `adminShellClient()`, `tenantClient()`, `auditClient()`, `jobsClient()`, optional `tierEngineClient()`.",
62
95
  );
63
96
  r.uiHints({
64
97
  displayLabel: "Admin Shell",
@@ -77,39 +110,35 @@ export function createAdminShellFeature(options: CreateAdminShellOptions = {}):
77
110
 
78
111
  r.translations({ keys: ADMIN_SHELL_I18N });
79
112
 
80
- const tenantNav: string[] = [
81
- "admin-shell:nav:tenant-overview",
82
- "tenant:nav:members",
83
- "audit:nav:audit-log",
84
- ...(includeCapOverview ? (["admin-shell:nav:my-caps"] as const) : []),
85
- ...(includeDocumentDesigner ? (["document-designer:nav:templates"] as const) : []),
86
- ];
87
- const platformNav: string[] = [
88
- "admin-shell:nav:platform-overview",
89
- "admin-shell:nav:tenants",
90
- ...(includeCapOverview ? (["admin-shell:nav:tenant-caps"] as const) : []),
91
- "audit:nav:audit-log-all-tenants",
92
- "jobs:nav:job-runs",
93
- ...(includeDocumentDesigner ? (["document-designer:nav:platform-templates"] as const) : []),
94
- ...(includeTierAdmin ? (["admin-shell:nav:tier-admin"] as const) : []),
95
- ];
96
-
97
- r.screen({
98
- id: TENANT_OVERVIEW_SCREEN_ID,
99
- type: "dashboard",
100
- access: { roles: access.admin },
101
- description: "admin-shell.screen.tenant-overview.subtitle",
102
- timeRange: OVERVIEW_TIME_RANGE,
103
- panels: [...tenantOverviewPanels(metrics, { includeCapOverview })],
113
+ const tenantNav = tenantWorkspaceNavIds({
114
+ includeTenantOverview,
115
+ includeCapOverview,
116
+ includeDocumentDesigner,
104
117
  });
105
- r.nav({
106
- id: "tenant-overview",
107
- label: "admin-shell:nav.tenantOverview",
108
- icon: "home",
109
- screen: "admin-shell:screen:tenant-overview",
110
- order: 1,
118
+ const platformNav = platformWorkspaceNavIds({
119
+ includeTierAdmin,
120
+ includeCapOverview,
121
+ includeDocumentDesigner,
111
122
  });
112
123
 
124
+ if (includeTenantOverview) {
125
+ r.screen({
126
+ id: TENANT_OVERVIEW_SCREEN_ID,
127
+ type: "dashboard",
128
+ access: { roles: access.admin },
129
+ description: "admin-shell.screen.tenant-overview.subtitle",
130
+ timeRange: OVERVIEW_TIME_RANGE,
131
+ panels: [...tenantOverviewPanels(metrics, { includeCapOverview })],
132
+ });
133
+ r.nav({
134
+ id: "tenant-overview",
135
+ label: "admin-shell:nav.tenantOverview",
136
+ icon: "home",
137
+ screen: "admin-shell:screen:tenant-overview",
138
+ order: 1,
139
+ });
140
+ }
141
+
113
142
  r.screen({
114
143
  id: PLATFORM_OVERVIEW_SCREEN_ID,
115
144
  type: "dashboard",
@@ -37,7 +37,7 @@ import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/error
37
37
  import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
38
38
  import * as z from "zod";
39
39
  import { decryptStoredPii, sessionLocaleField, sessionTimezoneField } from "../../shared/index.ts";
40
- // kumiko-lint-ignore cross-feature-import invite-flow
40
+ // kumiko-lint-ignore cross-feature-import the handler loads the pending invitation and marks it accepted
41
41
  import {
42
42
  INVITATION_STATUS,
43
43
  tenantInvitationEntity,
@@ -50,7 +50,7 @@ import {
50
50
  grantInvitedGlobalRoles,
51
51
  parseInvitedGlobalRoles,
52
52
  } from "../../user/invited-global-roles.ts";
53
- // kumiko-lint-ignore cross-feature-import login-style password-check
53
+ // kumiko-lint-ignore cross-feature-import the handler verifies the invited email password against the user row, like login
54
54
  import { userTable } from "../../user/schema/user.ts";
55
55
  import { invalidCredentials, invalidInviteToken, inviteEmailMismatch } from "../errors.ts";
56
56
  import {
@@ -33,7 +33,7 @@ import {
33
33
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
34
34
  import * as z from "zod";
35
35
  import { decryptStoredPii, registrationLocaleFromRequest } from "../../shared/index.ts";
36
- // kumiko-lint-ignore cross-feature-import invite-flow
36
+ // kumiko-lint-ignore cross-feature-import the handler loads the pending invitation and marks it accepted
37
37
  import {
38
38
  INVITATION_STATUS,
39
39
  tenantInvitationEntity,
@@ -48,7 +48,7 @@ import {
48
48
  import { seedTenantMembership } from "../../tenant/seeding.ts";
49
49
  // kumiko-lint-ignore cross-feature-import global roles granted on invite accept live on the user row
50
50
  import { parseInvitedGlobalRoles } from "../../user/invited-global-roles.ts";
51
- // kumiko-lint-ignore cross-feature-import existence-check
51
+ // kumiko-lint-ignore cross-feature-import the handler rejects an invitation whose email already has a user row
52
52
  import { userTable } from "../../user/schema/user.ts";
53
53
  import { invalidInviteToken } from "../errors.ts";
54
54
  import {
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.392.0",
4
+ "type": "improvement",
5
+ "title": "Cap amounts follow their unit and a limit of 0 reads \"Not included\"",
6
+ "detail": "Values with unit \"mb\" show as MB, GB or TB (base 1024), count and tokens use the locale's digit grouping. A limit of 0 or less shows \"Not included\" instead of a 0 bar, also when nothing is measured. caps:usage rows and the tenant-caps list cells now carry the unit of the cap. New key cap-overview.notIncluded in all locale packages."
7
+ },
8
+ {
9
+ "version": "0.392.0",
10
+ "type": "improvement",
11
+ "title": "CapCardsPanel can be limited to specific cap keys via params.capKeys",
12
+ "detail": "A custom dashboard panel with params capKeys as a list of cap ids shows only those cards, in the order of the capsUsage query. Anything other than a list of strings means no limit, and capKeys is not sent to the server. Each limited cap card also shows how much is left (or that nothing is), next to the percent chip, and a limited panel adds a link to the full usage screen."
13
+ },
2
14
  {
3
15
  "version": "0.383.0",
4
16
  "type": "improvement",
@@ -86,6 +86,7 @@ export function createCapsUsageQuery(
86
86
  id: cap.id,
87
87
  label: cap.label,
88
88
  limit,
89
+ ...(cap.unit !== undefined && { unit: cap.unit }),
89
90
  ...(cap.icon !== undefined && { icon: cap.icon }),
90
91
  ...(cap.accentColor !== undefined && { accentColor: cap.accentColor }),
91
92
  };
@@ -262,10 +262,11 @@ export function createTenantCapsListQuery(caps: readonly CapSpec[], listCaps: re
262
262
  const rawUsed = usageByCap.get(cap.id)?.get(row.tenantId);
263
263
  const used = rawUsed === undefined ? 0 : rawUsed;
264
264
  const limit = limitByTenantAndCap.get(tenantCapKey(row.tenantId, cap.id)) ?? null;
265
+ const unit = cap.unit !== undefined ? { unit: cap.unit } : {};
265
266
  capFields[capFieldName(cap.id)] =
266
267
  used === null
267
- ? { used: null, limit, fraction: 0 }
268
- : { used, limit, fraction: computeFraction(used, limit) };
268
+ ? { used: null, limit, fraction: 0, ...unit }
269
+ : { used, limit, fraction: computeFraction(used, limit), ...unit };
269
270
  }
270
271
  return {
271
272
  tenantId: row.tenantId,
@@ -12,7 +12,11 @@ export const CAP_OVERVIEW_I18N: Readonly<Record<string, LocalizedString>> = {
12
12
  "cap-overview.platform.filter.tenant": { en: "Tenant" },
13
13
  "cap-overview.cards.empty": { en: "No caps configured for this tenant." },
14
14
  "cap-overview.cards.loading": { en: "Loading usage…" },
15
+ "cap-overview.cards.remaining": { en: "{amount} left" },
16
+ "cap-overview.cards.noneLeft": { en: "None left" },
17
+ "cap-overview.cards.viewAll": { en: "View all usage" },
15
18
  "cap-overview.notMeasured": { en: "Not measured yet" },
19
+ "cap-overview.notIncluded": { en: "Not included" },
16
20
  "cap-overview.unlimited": { en: "{used} · unlimited" },
17
21
  "cap-overview.errors.progressPrimitiveMissing": {
18
22
  en: "Usage bar unavailable — Progress primitive is not registered.",
@@ -2,6 +2,8 @@ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
2
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import type { CapLimitContext } from "../cap-counter/index.ts";
4
4
 
5
+ export type CapUnit = "count" | "mb" | "tokens";
6
+
5
7
  // Closed vocabulary for the dashboard cards' icon chip — cap-cards-panel.tsx
6
8
  // holds the matching lucide-react lookup. Small on purpose (YAGNI): extend
7
9
  // both together when a cap needs a shape not covered here.
@@ -35,7 +37,7 @@ export type CapSpec = {
35
37
  db: TenantDb,
36
38
  tenantIds: readonly TenantId[],
37
39
  ) => Promise<Map<TenantId, number | null>>;
38
- readonly unit?: "count" | "mb" | "tokens";
40
+ readonly unit?: CapUnit;
39
41
  /** Icon chip on the dashboard cards. Optional — omitted renders the card
40
42
  * without an icon, same as before this field existed. */
41
43
  readonly icon?: CapIconKey;
@@ -54,6 +56,7 @@ export type CapUsage = {
54
56
  // `limit: null` means unlimited — a pure usage meter with no cap to hit.
55
57
  readonly limit: number | null;
56
58
  readonly fraction: number;
59
+ readonly unit?: CapUnit;
57
60
  };
58
61
 
59
62
  export type CapUsageWithMeta = CapUsage & {
@@ -0,0 +1,37 @@
1
+ import type { CapUnit } from "../types.ts";
2
+
3
+ const MB_PER_GB = 1024;
4
+ const MB_PER_TB = 1024 * 1024;
5
+
6
+ function megabyteUnit(valueInMb: number): { readonly unit: string; readonly divisor: number } {
7
+ const magnitude = Math.abs(valueInMb);
8
+ if (magnitude < MB_PER_GB) return { unit: "megabyte", divisor: 1 };
9
+ if (magnitude < MB_PER_TB) return { unit: "gigabyte", divisor: MB_PER_GB };
10
+ return { unit: "terabyte", divisor: MB_PER_TB };
11
+ }
12
+
13
+ export function formatCapAmount(value: number, unit: CapUnit | undefined, locale: string): string {
14
+ try {
15
+ if (unit === "mb") {
16
+ const scaled = megabyteUnit(value);
17
+ return new Intl.NumberFormat(locale, {
18
+ style: "unit",
19
+ unit: scaled.unit,
20
+ unitDisplay: "short",
21
+ maximumFractionDigits: 1,
22
+ }).format(value / scaled.divisor);
23
+ }
24
+ return new Intl.NumberFormat(locale).format(value);
25
+ } catch {
26
+ return String(value);
27
+ }
28
+ }
29
+
30
+ export function formatCapUsedOfLimit(
31
+ used: number,
32
+ limit: number,
33
+ unit: CapUnit | undefined,
34
+ locale: string,
35
+ ): string {
36
+ return `${formatCapAmount(used, unit, locale)} / ${formatCapAmount(limit, unit, locale)}`;
37
+ }
@@ -1,6 +1,12 @@
1
1
  import type { ExtensionSectionProps } from "@cosmicdrift/kumiko-renderer";
2
- import { usePrimitives, useQuery, useTranslation } from "@cosmicdrift/kumiko-renderer";
3
- import { EMPTY_VALUE_PLACEHOLDER } from "@cosmicdrift/kumiko-renderer/headless";
2
+ import {
3
+ useLocale,
4
+ useNav,
5
+ usePrimitives,
6
+ useQuery,
7
+ useTranslation,
8
+ } from "@cosmicdrift/kumiko-renderer";
9
+ import { EMPTY_VALUE_PLACEHOLDER, type Translate } from "@cosmicdrift/kumiko-renderer/headless";
4
10
  import {
5
11
  EmptyState,
6
12
  StatCard,
@@ -9,9 +15,11 @@ import {
9
15
  } from "@cosmicdrift/kumiko-renderer/web";
10
16
  import { Database, FileText, Gauge, Hash, Mail, Users } from "lucide-react";
11
17
  import type { ComponentType, ReactNode } from "react";
12
- import type { CapIconKey, CapUsageTone, CapUsageWithMeta } from "../types.ts";
18
+ import type { CapIconKey, CapUnit, CapUsageTone, CapUsageWithMeta } from "../types.ts";
19
+ import { formatCapAmount, formatCapUsedOfLimit } from "./cap-amount-format.ts";
13
20
  import { CapUsageBar } from "./cap-usage-bar.tsx";
14
- import { CapOverviewQueries } from "./constants.ts";
21
+ import { CapOverviewQueries, MY_CAPS_SCREEN_ID } from "./constants.ts";
22
+ import { isCapNotIncluded } from "./usage-math.ts";
15
23
 
16
24
  type CapsUsageResponse = { readonly rows: readonly CapUsageWithMeta[] };
17
25
 
@@ -41,6 +49,106 @@ function isMeasured(cap: CapUsageWithMeta): cap is CapUsageWithMeta & { used: nu
41
49
  return cap.used !== null;
42
50
  }
43
51
 
52
+ // Boundary check: only an array of strings restricts; anything else means no restriction.
53
+ function readCapKeys(value: unknown): ReadonlySet<string> | undefined {
54
+ if (!Array.isArray(value)) return undefined;
55
+ return new Set(value.filter((entry): entry is string => typeof entry === "string"));
56
+ }
57
+
58
+ function ViewAllLink(): ReactNode {
59
+ const t = useTranslation();
60
+ const { Link } = usePrimitives();
61
+ const nav = useNav();
62
+ const target = { screenId: MY_CAPS_SCREEN_ID };
63
+ return (
64
+ <Link
65
+ href={nav.hrefFor(target)}
66
+ onPress={() => nav.navigate(target)}
67
+ variant="muted"
68
+ testId="cap-cards-view-all"
69
+ >
70
+ {t("cap-overview.cards.viewAll")}
71
+ </Link>
72
+ );
73
+ }
74
+
75
+ function CapUsageSummaryRow({ cap }: { readonly cap: CapUsageWithMeta }): ReactNode {
76
+ const t = useTranslation();
77
+ const locale = useLocale().locale();
78
+ const remainingText =
79
+ isMeasured(cap) && cap.limit !== null && cap.limit > 0
80
+ ? remainingLabel(t, Math.max(cap.limit - cap.used, 0), cap.unit, locale)
81
+ : undefined;
82
+ if (cap.percent === null && remainingText === undefined) return null;
83
+ return (
84
+ <div className="flex items-center gap-2">
85
+ {cap.percent !== null && (
86
+ <StatusBadge tone={TONE_TO_STATUS[cap.tone]}>{`${cap.percent}%`}</StatusBadge>
87
+ )}
88
+ {remainingText !== undefined && (
89
+ <span className="text-sm text-muted-foreground" data-testid="cap-card-remaining">
90
+ {remainingText}
91
+ </span>
92
+ )}
93
+ </div>
94
+ );
95
+ }
96
+
97
+ function remainingLabel(
98
+ t: Translate,
99
+ rest: number,
100
+ unit: CapUnit | undefined,
101
+ locale: string,
102
+ ): string {
103
+ return rest > 0
104
+ ? t("cap-overview.cards.remaining", { amount: formatCapAmount(rest, unit, locale) })
105
+ : t("cap-overview.cards.noneLeft");
106
+ }
107
+
108
+ function CapUsageStat({
109
+ cap,
110
+ footer,
111
+ }: {
112
+ readonly cap: CapUsageWithMeta;
113
+ readonly footer?: ReactNode;
114
+ }): ReactNode {
115
+ const t = useTranslation();
116
+ const locale = useLocale().locale();
117
+ const Icon = cap.icon !== undefined ? CAP_ICONS[cap.icon] : undefined;
118
+ // An unlimited, measured cap has neither a badge nor a bar; StatCard still
119
+ // wraps any defined child in a spacing div, so pass undefined then.
120
+ const notIncluded = isCapNotIncluded(cap.limit);
121
+ const body =
122
+ !notIncluded && (!isMeasured(cap) || cap.limit !== null) ? (
123
+ <div className="flex flex-col gap-2">
124
+ <CapUsageSummaryRow cap={cap} />
125
+ <CapUsageBar usage={cap} showLabel={false} />
126
+ {footer}
127
+ </div>
128
+ ) : (
129
+ footer
130
+ );
131
+ return (
132
+ <StatCard
133
+ icon={Icon !== undefined ? <Icon className="size-4" /> : undefined}
134
+ label={t(cap.label)}
135
+ value={
136
+ notIncluded
137
+ ? t("cap-overview.notIncluded")
138
+ : !isMeasured(cap)
139
+ ? EMPTY_VALUE_PLACEHOLDER
140
+ : cap.limit === null
141
+ ? formatCapAmount(cap.used, cap.unit, locale)
142
+ : formatCapUsedOfLimit(cap.used, cap.limit, cap.unit, locale)
143
+ }
144
+ {...(cap.accentColor !== undefined && { accentColor: cap.accentColor })}
145
+ testId="cap-card"
146
+ >
147
+ {body}
148
+ </StatCard>
149
+ );
150
+ }
151
+
44
152
  export function CapCardsPanel({ filterParams }: ExtensionSectionProps): ReactNode {
45
153
  const t = useTranslation();
46
154
  const { Banner } = usePrimitives();
@@ -58,62 +166,38 @@ export function CapCardsPanel({ filterParams }: ExtensionSectionProps): ReactNod
58
166
  );
59
167
  }
60
168
 
61
- const rows = query.data?.rows ?? [];
169
+ const capKeys = readCapKeys(filterParams?.["capKeys"]);
170
+ const allRows = query.data?.rows ?? [];
171
+ const rows = capKeys === undefined ? allRows : allRows.filter((row) => capKeys.has(row.id));
62
172
 
63
173
  if (!query.loading && rows.length === 0) {
64
174
  return <EmptyState title={t("cap-overview.cards.empty")} testId="cap-cards-panel-empty" />;
65
175
  }
66
176
 
177
+ // A single limited card carries the link itself; several share one under the grid.
178
+ const linkInCard = capKeys !== undefined && rows.length === 1;
67
179
  return (
68
- <div
69
- // Arbitrary bracket values (grid-cols-[repeat(...)]) never compiled:
70
- // styles.css's Tailwind v4 @source scan only covers renderer/web/src,
71
- // renderer/src and samples/**/src (see its comment) — bundled-features
72
- // isn't scanned, so a class unique to this file has no generated rule
73
- // and silently no-ops (the grid fell back to one implicit column,
74
- // stacking every card full-width). Reusing the exact classes
75
- // dashboard-body.tsx's own panel grid already uses guarantees the
76
- // rule exists, since that file IS in the scanned surface.
77
- className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
78
- data-testid="cap-cards-panel"
79
- >
80
- {query.loading && rows.length === 0 && (
81
- <div className="text-sm text-muted-foreground">{t("cap-overview.cards.loading")}</div>
82
- )}
83
- {rows.map((cap) => {
84
- const Icon = cap.icon !== undefined ? CAP_ICONS[cap.icon] : undefined;
85
- // An unlimited, measured cap has neither a badge nor a bar; StatCard still
86
- // wraps any defined child in a spacing div, so pass undefined then.
87
- const body =
88
- !isMeasured(cap) || cap.limit !== null ? (
89
- <div className="flex flex-col gap-2">
90
- {cap.percent !== null && (
91
- <div className="flex items-center justify-between gap-2">
92
- <StatusBadge tone={TONE_TO_STATUS[cap.tone]}>{`${cap.percent}%`}</StatusBadge>
93
- </div>
94
- )}
95
- <CapUsageBar usage={cap} showLabel={false} />
96
- </div>
97
- ) : undefined;
98
- return (
99
- <StatCard
100
- key={cap.id}
101
- icon={Icon !== undefined ? <Icon className="size-4" /> : undefined}
102
- label={t(cap.label)}
103
- value={
104
- !isMeasured(cap)
105
- ? EMPTY_VALUE_PLACEHOLDER
106
- : cap.limit === null
107
- ? String(cap.used)
108
- : `${cap.used} / ${cap.limit}`
109
- }
110
- {...(cap.accentColor !== undefined && { accentColor: cap.accentColor })}
111
- testId="cap-card"
112
- >
113
- {body}
114
- </StatCard>
115
- );
116
- })}
180
+ <div className="flex flex-col gap-2">
181
+ <div
182
+ // Arbitrary bracket values (grid-cols-[repeat(...)]) never compiled:
183
+ // styles.css's Tailwind v4 @source scan only covers renderer/web/src,
184
+ // renderer/src and samples/**/src (see its comment) — bundled-features
185
+ // isn't scanned, so a class unique to this file has no generated rule
186
+ // and silently no-ops (the grid fell back to one implicit column,
187
+ // stacking every card full-width). Reusing the exact classes
188
+ // dashboard-body.tsx's own panel grid already uses guarantees the
189
+ // rule exists, since that file IS in the scanned surface.
190
+ className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
191
+ data-testid="cap-cards-panel"
192
+ >
193
+ {query.loading && rows.length === 0 && (
194
+ <div className="text-sm text-muted-foreground">{t("cap-overview.cards.loading")}</div>
195
+ )}
196
+ {rows.map((cap) => (
197
+ <CapUsageStat key={cap.id} cap={cap} footer={linkInCard ? <ViewAllLink /> : undefined} />
198
+ ))}
199
+ </div>
200
+ {capKeys !== undefined && !linkInCard && <ViewAllLink />}
117
201
  </div>
118
202
  );
119
203
  }
@@ -1,7 +1,8 @@
1
- import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
1
+ import { useLocale, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
2
2
  import type { ReactNode } from "react";
3
3
  import type { CapUsage } from "../types.ts";
4
- import { computeTone } from "./usage-math.ts";
4
+ import { formatCapAmount, formatCapUsedOfLimit } from "./cap-amount-format.ts";
5
+ import { computeTone, isCapNotIncluded } from "./usage-math.ts";
5
6
 
6
7
  export function CapUsageBar({
7
8
  usage,
@@ -14,6 +15,15 @@ export function CapUsageBar({
14
15
  }): ReactNode {
15
16
  const { Progress, Text, Banner } = usePrimitives();
16
17
  const t = useTranslation();
18
+ const locale = useLocale().locale();
19
+
20
+ if (isCapNotIncluded(usage.limit)) {
21
+ return (
22
+ <Text variant="small" testId="cap-usage-not-included">
23
+ {t("cap-overview.notIncluded")}
24
+ </Text>
25
+ );
26
+ }
17
27
 
18
28
  if (usage.used === null) {
19
29
  return (
@@ -27,7 +37,7 @@ export function CapUsageBar({
27
37
  // A progress bar with no denominator says nothing — show the raw count instead.
28
38
  return showLabel ? (
29
39
  <Text variant="small" testId="cap-usage-unlimited">
30
- {t("cap-overview.unlimited", { used: usage.used })}
40
+ {t("cap-overview.unlimited", { used: formatCapAmount(usage.used, usage.unit, locale) })}
31
41
  </Text>
32
42
  ) : null;
33
43
  }
@@ -49,7 +59,11 @@ export function CapUsageBar({
49
59
  {t("cap-overview.errors.progressPrimitiveMissing")}
50
60
  </Banner>
51
61
  )}
52
- {showLabel && <Text variant="small">{`${usage.used} / ${usage.limit}`}</Text>}
62
+ {showLabel && (
63
+ <Text variant="small">
64
+ {formatCapUsedOfLimit(usage.used, usage.limit, usage.unit, locale)}
65
+ </Text>
66
+ )}
53
67
  </div>
54
68
  );
55
69
  }
@@ -1,9 +1,11 @@
1
1
  import type { ColumnRendererProps } from "@cosmicdrift/kumiko-renderer";
2
- import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
2
+ import { isOneOf, isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
3
3
  import type { ReactNode } from "react";
4
- import type { CapUsage } from "../types.ts";
4
+ import type { CapUnit, CapUsage } from "../types.ts";
5
5
  import { CapUsageBar } from "./cap-usage-bar.tsx";
6
6
 
7
+ const CAP_UNITS: readonly CapUnit[] = ["count", "mb", "tokens"];
8
+
7
9
  function isCapUsage(value: unknown): value is CapUsage {
8
10
  if (!isPlainObject(value)) return false;
9
11
  const used = value["used"];
@@ -11,7 +13,8 @@ function isCapUsage(value: unknown): value is CapUsage {
11
13
  return (
12
14
  (typeof used === "number" || used === null) &&
13
15
  (typeof limit === "number" || limit === null) &&
14
- typeof value["fraction"] === "number"
16
+ typeof value["fraction"] === "number" &&
17
+ (value["unit"] === undefined || isOneOf(CAP_UNITS, value["unit"]))
15
18
  );
16
19
  }
17
20
 
@@ -8,9 +8,13 @@ const DANGER_THRESHOLD = 1;
8
8
 
9
9
  // limit === null means unlimited (no cap to measure against); limit <= 0
10
10
  // means "not part of this tier" — both render as 0, never NaN/Infinity.
11
+ export function isCapNotIncluded(limit: number | null): boolean {
12
+ return limit !== null && limit <= 0;
13
+ }
14
+
11
15
  function rawFraction(used: number, limit: number | null): number {
12
16
  if (limit === null) return 0;
13
- if (limit <= 0) return 0;
17
+ if (isCapNotIncluded(limit)) return 0;
14
18
  const fraction = used / limit;
15
19
  return Number.isFinite(fraction) ? fraction : 0;
16
20
  }