ad2app-lib 1.9.3 → 1.11.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.
@@ -0,0 +1,21 @@
1
+ name: test
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [master, main]
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-node@v4
14
+ with:
15
+ node-version: 20
16
+ cache: npm
17
+ - run: npm ci
18
+ - name: typecheck
19
+ run: npx tsc --noEmit
20
+ - name: test
21
+ run: npm test
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Type declarations for `brand/brand.mjs` — the canonical ad2app brand source.
3
+ *
4
+ * Hand-maintained to mirror brand.mjs (plain ESM, imported buildless by the
5
+ * node scripts). Published via the `./brand` export so TS consumers (frontend,
6
+ * landing) can import these tokens + facts with types. Keep in sync with brand.mjs.
7
+ */
8
+
9
+ export interface ColorTokens {
10
+ bg: string;
11
+ surface: string;
12
+ surfaceMuted: string;
13
+ text: string;
14
+ muted: string;
15
+ border: string;
16
+ primary: string;
17
+ primaryHover: string;
18
+ primaryTint: string;
19
+ primarySolidHover: string;
20
+ onPrimary: string;
21
+ success: string;
22
+ successText: string;
23
+ error: string;
24
+ /** The PRODUCT blue, deliberately distinct from the marketing primary. */
25
+ productPrimary: string;
26
+ }
27
+ export const color: ColorTokens;
28
+
29
+ export interface FontTokens {
30
+ family: string;
31
+ fallback: string;
32
+ }
33
+ export const font: FontTokens;
34
+
35
+ export interface CanvasSize {
36
+ width: number;
37
+ height: number;
38
+ }
39
+ export interface SizeTokens {
40
+ story: CanvasSize;
41
+ portrait: CanvasSize;
42
+ square: CanvasSize;
43
+ link: CanvasSize;
44
+ xLandscape: CanvasSize;
45
+ xPortrait: CanvasSize;
46
+ }
47
+ export const size: SizeTokens;
48
+
49
+ export interface RadiusTokens {
50
+ card: number;
51
+ pill: number;
52
+ chip: number;
53
+ }
54
+ export const radius: RadiusTokens;
55
+
56
+ export interface AestheticTokens {
57
+ feel: string[];
58
+ avoid: string[];
59
+ prefer: string[];
60
+ bannedCliches: string[];
61
+ }
62
+ export const aesthetic: AestheticTokens;
63
+
64
+ /** A connectable social platform's canonical brand identity. */
65
+ export interface PlatformBrand {
66
+ slug: string;
67
+ name: string;
68
+ /** Official brand color (hex). */
69
+ color: string;
70
+ /** On-dark override for marks that are near-black (invert to white in dark mode). */
71
+ darkColor?: string;
72
+ }
73
+ /** The connectable platforms, canonical order (largest-audience first). */
74
+ export const PLATFORMS: PlatformBrand[];
75
+ export const PLATFORM_COUNT: number;
76
+
77
+ export interface PlanPricing {
78
+ usd: number;
79
+ note?: string;
80
+ accounts?: number;
81
+ }
82
+ export interface PricingTokens {
83
+ free: PlanPricing;
84
+ starter: PlanPricing;
85
+ pro: PlanPricing;
86
+ }
87
+ export const PRICING: PricingTokens;
88
+
89
+ export const ENTITY_SENTENCE: string;
@@ -0,0 +1,91 @@
1
+ // ad2app canonical brand source: design tokens + facts. Plain ESM on purpose, so the
2
+ // on-device node scripts (graphics renderer, the marketing claims gate) import it directly
3
+ // today with no build and no publish. The landing + product join via a typed, published
4
+ // export under spec 009-design-system-hardening. Change a token or a fact HERE, once.
5
+ // Values currently mirror ad2app-landing globals.css + the marketing positioning. _(scope: rule)_
6
+
7
+ // ---- Design tokens ----
8
+ export const color = {
9
+ bg: '#f7f8ff',
10
+ surface: '#ffffff',
11
+ surfaceMuted: '#f0f1fa',
12
+ text: '#1a1a1a',
13
+ muted: '#5f6672',
14
+ border: '#d0d0d8',
15
+ primary: '#0042ff', // marketing primary
16
+ primaryHover: '#0036d6',
17
+ primaryTint: '#eaf0ff',
18
+ primarySolidHover: '#3366ff',
19
+ onPrimary: '#ffffff',
20
+ success: '#34c759',
21
+ successText: '#0f7a33',
22
+ error: '#ff3b30',
23
+ productPrimary: '#0000ff', // the PRODUCT blue, deliberately distinct from marketing #0042ff
24
+ };
25
+
26
+ export const font = {
27
+ family: 'Funnel Sans',
28
+ fallback: '-apple-system, Segoe UI, Roboto, Helvetica Neue, sans-serif',
29
+ };
30
+
31
+ // Social canvas sizes (px), the canonical ad set.
32
+ export const size = {
33
+ story: { width: 1080, height: 1920 }, // 9:16 TikTok/Reels/Shorts/Stories
34
+ portrait: { width: 1080, height: 1350 }, // 4:5 IG/FB feed
35
+ square: { width: 1080, height: 1080 }, // 1:1 Meta/X/Reddit feed
36
+ link: { width: 1200, height: 628 }, // 1.91:1 link/display
37
+ xLandscape: { width: 1200, height: 675 }, // X single image
38
+ xPortrait: { width: 1080, height: 1350 }, // back-compat alias of portrait
39
+ };
40
+
41
+ export const radius = { card: 28, pill: 999, chip: 12 };
42
+
43
+ // Visual identity, machine-readable. The PRINCIPLES + art direction live in
44
+ // ad2app-graphics/data/design-trends.md (the design source); this is the encoding the
45
+ // graphics generate/critique loop consumes.
46
+ export const aesthetic = {
47
+ // Look doctrine = ad2app-graphics/data/design-trends.md (4 directions: human/life-given-back [default],
48
+ // type-led minimalism, soft-3D, minimaximalism). North star: WHITE / light, calm, warm, intentional —
49
+ // NEVER the dark premium-dev/attention-economy look. Brand blue #0042ff is the HERO accent; oversized
50
+ // Funnel Sans type. Audience = creators + young founders, but the bar is warm + scroll-stopping, not "Linear".
51
+ feel: ['calm', 'intentional', 'warm', 'human', 'confident', 'spacious', 'crafted', 'present'],
52
+ avoid: ['dark / moody / premium-dev sterility (Linear-tier coldness) as a default', 'frantic, attention-economy energy', 'cluttered', 'growth-hack energy', 'busy collage', 'stock-photo gloss', 'golden-hour brunch / wellness-stock lifestyle', 'timid small centered type on an empty field'],
53
+ // Hero = confident oversized type + #0042ff on a calm light ground. Real product UI is fine and trust-building;
54
+ // invented metric/dashboard chrome is banned (use a real screenshot). Human shots show the life given back.
55
+ prefer: ['WHITE / light calm ground (#f7f8ff / #ffffff), not dark', 'confident OVERSIZED Funnel Sans type as the hero', 'exact brand blue #0042ff as the HERO accent — highlight blocks, blue fields — never navy or grey', 'minimaximalism: a sleek minimal base + a few vibrant, oversized accents', 'the life given back for human shots (closed laptop, free evening, off the phone)', 'generous, composed negative space', 'one idea per asset'],
56
+ bannedCliches: ['network nodes', 'circuit lines / digital-tech glow', 'floating app icons', 'AI brain', 'fake dashboards', 'invented metric chrome', 'emoji spam', 'dashes', 'dark moody tech sterility'],
57
+ };
58
+
59
+ // ---- Facts (stable, machine-consumable) ----
60
+ // The 10 connectable platforms, canonical order (largest-audience first).
61
+ // `color` is each platform's official brand spec; `darkColor` is the on-dark
62
+ // override for marks that are near-black (they invert to white in dark mode).
63
+ // These are the single source for per-platform brand color across product +
64
+ // landing (the typed published export joins them under spec 009).
65
+ export const PLATFORMS = [
66
+ { slug: 'instagram', name: 'Instagram', color: '#E1306C' },
67
+ { slug: 'tiktok', name: 'TikTok', color: '#000000', darkColor: '#ffffff' },
68
+ { slug: 'youtube', name: 'YouTube', color: '#FF0000' },
69
+ { slug: 'x-twitter', name: 'X (Twitter)', color: '#000000', darkColor: '#ffffff' },
70
+ { slug: 'facebook', name: 'Facebook', color: '#1877F2' },
71
+ { slug: 'linkedin', name: 'LinkedIn', color: '#0A66C2' },
72
+ { slug: 'threads', name: 'Threads', color: '#000000', darkColor: '#ffffff' },
73
+ { slug: 'pinterest', name: 'Pinterest', color: '#E60023' },
74
+ { slug: 'reddit', name: 'Reddit', color: '#FF4500' },
75
+ { slug: 'bluesky', name: 'Bluesky', color: '#0085FF' },
76
+ ];
77
+ export const PLATFORM_COUNT = PLATFORMS.length; // 10
78
+
79
+ // Pricing. free is view-only (NOT a trial). Plan keys: free / starter / pro.
80
+ // NOTE: the landing FAQ currently labels pro as "All-Platforms" — a name drift to reconcile in 009.
81
+ export const PRICING = {
82
+ free: { usd: 0, note: 'view-only, cannot connect accounts' },
83
+ starter: { usd: 16, accounts: 4 },
84
+ pro: { usd: 33, accounts: 10 },
85
+ };
86
+
87
+ // Canonical entity sentence (byte-identical across landing footer/JSON-LD/metadata/manifest).
88
+ // Identity-aligned 2026-06-29. Still a clean factual sentence for SEO/AEO (keeps the 10-platform list);
89
+ // the landing adopts this via spec 009 (today the landing still carries its own copy).
90
+ export const ENTITY_SENTENCE =
91
+ 'ad2app is a social scheduler and unified inbox for creators and founders: schedule and publish across 10 platforms (Instagram, TikTok, X, YouTube, LinkedIn, Facebook, Threads, Pinterest, Reddit, and Bluesky), then handle every comment and DM in one place. Social on purpose.';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ad2app-lib",
3
- "version": "1.9.3",
3
+ "version": "1.11.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "commonjs",
@@ -9,7 +9,8 @@
9
9
  "./types": "./dist/types/index.js",
10
10
  "./api": "./dist/api/index.js",
11
11
  "./legal": "./dist/legal/index.js",
12
- "./analytics": "./dist/analytics/index.js"
12
+ "./analytics": "./dist/analytics/index.js",
13
+ "./brand": "./brand/brand.mjs"
13
14
  },
14
15
  "typesVersions": {
15
16
  "*": {
@@ -28,6 +29,9 @@
28
29
  "analytics": [
29
30
  "dist/analytics/index.d.ts"
30
31
  ],
32
+ "brand": [
33
+ "brand/brand.d.ts"
34
+ ],
31
35
  "*": [
32
36
  "dist/index.d.ts"
33
37
  ]
@@ -9,6 +9,7 @@ import assert from "node:assert/strict";
9
9
  import { test } from "node:test";
10
10
 
11
11
  import { EVENTS, PERSON_PROPS } from "./index";
12
+ import type { EventProperties, EmailEventProperties } from "./index";
12
13
 
13
14
  const SNAKE_CASE = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
14
15
 
@@ -49,3 +50,72 @@ test("critical person property keys are locked", () => {
49
50
  assert.equal(PERSON_PROPS.PLAN, "plan");
50
51
  assert.equal(PERSON_PROPS.ROLE, "role");
51
52
  });
53
+
54
+ /**
55
+ * AD2-952 — property-shape coverage. The compile-time 1:1 guarantee between EVENTS
56
+ * and EventProperties lives in index.ts (enforced by `tsc --noEmit`); these runtime
57
+ * tests lock the same contract under `npm test` and pin the email-lifecycle shape.
58
+ */
59
+
60
+ // One witness payload per event, typed by EventProperties so the map is forced to
61
+ // carry EXACTLY the EventProperties keys; the assertions below verify it stays 1:1
62
+ // with the runtime EVENTS const.
63
+ const EVENT_PROPERTY_WITNESS: { [E in keyof EventProperties]: EventProperties[E] } = {
64
+ [EVENTS.LANDING_CTA_CLICKED]: { location: "hero" },
65
+ [EVENTS.CONNECT_ARTIFACT_COPIED]: { target: "app" },
66
+ [EVENTS.FREE_SKILLS_REQUESTED]: { source: "landing" },
67
+ [EVENTS.ACCOUNT_CONNECT_BLOCKED]: {},
68
+ [EVENTS.MCP_POST_BLOCKED_FREE_TIER]: {},
69
+ [EVENTS.EMAIL_DELIVERED]: {},
70
+ [EVENTS.EMAIL_OPENED]: {},
71
+ [EVENTS.EMAIL_CLICKED]: {},
72
+ [EVENTS.EMAIL_BOUNCED]: {},
73
+ [EVENTS.EMAIL_COMPLAINED]: {},
74
+ [EVENTS.SIGNED_UP]: { method: "email", role: "creator" },
75
+ [EVENTS.PROFILE_COMPLETED]: { role: "creator" },
76
+ [EVENTS.LOGGED_IN]: {},
77
+ [EVENTS.SOCIAL_ACCOUNT_CONNECTED]: { platform: "instagram" },
78
+ [EVENTS.COMPOSE_STARTED]: {},
79
+ [EVENTS.POST_SCHEDULED]: { platforms: ["instagram"], platform_count: 1 },
80
+ [EVENTS.POST_PUBLISHED]: { platforms: ["instagram"], platform_count: 1, status: "success" },
81
+ [EVENTS.ANALYTICS_VIEWED]: {},
82
+ [EVENTS.LIMIT_REACHED]: { limit: "accounts", plan: "free", cap: 1 },
83
+ [EVENTS.UPGRADE_CLICKED]: { surface: "compose" },
84
+ [EVENTS.CHECKOUT_STARTED]: { plan: "starter" },
85
+ [EVENTS.SUBSCRIPTION_STARTED]: { plan: "starter" },
86
+ [EVENTS.SUBSCRIPTION_CANCELED]: { plan: "starter" },
87
+ };
88
+
89
+ test("EVENTS values are 1:1 with EventProperties keys (no missing or typo'd event)", () => {
90
+ const eventValues = Object.values(EVENTS).sort();
91
+ const propertyKeys = Object.keys(EVENT_PROPERTY_WITNESS).sort();
92
+ assert.deepEqual(propertyKeys, eventValues);
93
+ });
94
+
95
+ test("the 5 EMAIL_* events are locked and share the EmailEventProperties shape (AD2-894)", () => {
96
+ const emailEvents = [
97
+ EVENTS.EMAIL_DELIVERED,
98
+ EVENTS.EMAIL_OPENED,
99
+ EVENTS.EMAIL_CLICKED,
100
+ EVENTS.EMAIL_BOUNCED,
101
+ EVENTS.EMAIL_COMPLAINED,
102
+ ];
103
+ assert.deepEqual(emailEvents, [
104
+ "email_delivered",
105
+ "email_opened",
106
+ "email_clicked",
107
+ "email_bounced",
108
+ "email_complained",
109
+ ]);
110
+
111
+ // The shared shape exposes only email_id / subject / link (all optional).
112
+ const allowedKeys = ["email_id", "link", "subject"];
113
+ const sample: EmailEventProperties = { email_id: "re_1", subject: "Welcome", link: "https://ad2.app" };
114
+ assert.deepEqual(Object.keys(sample).sort(), allowedKeys);
115
+ for (const event of emailEvents) {
116
+ const witness = EVENT_PROPERTY_WITNESS[event];
117
+ for (const key of Object.keys(witness)) {
118
+ assert.ok(allowedKeys.includes(key), `unexpected key on ${event}: ${key}`);
119
+ }
120
+ }
121
+ });
@@ -123,3 +123,28 @@ export const PERSON_PROPS = {
123
123
  } as const;
124
124
 
125
125
  export type PersonPropKey = (typeof PERSON_PROPS)[keyof typeof PERSON_PROPS];
126
+
127
+ /**
128
+ * Compile-time taxonomy contract (AD2-952). These are pure type-level assertions:
129
+ * if EVENTS and EventProperties ever drift, the `tsc --noEmit` CI gate fails the
130
+ * build — a renamed, typo'd, or unmapped event cannot ship silently. They emit no
131
+ * runtime code and, being non-exported, do not appear in the package's public types.
132
+ */
133
+ type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
134
+ ? true
135
+ : false;
136
+ type Expect<T extends true> = T;
137
+
138
+ // AC2: every EVENTS value maps to exactly one EventProperties entry — no missing
139
+ // property shape, no orphan or typo'd key (1:1).
140
+ type _EventsAreOneToOneWithProperties = Expect<Equal<EventName, keyof EventProperties>>;
141
+
142
+ // AC3: the five EMAIL_* lifecycle events all carry the shared EmailEventProperties
143
+ // shape (the Resend -> PostHog bridge contract, AD2-894).
144
+ type EmailLifecycleEvent =
145
+ | (typeof EVENTS)['EMAIL_DELIVERED']
146
+ | (typeof EVENTS)['EMAIL_OPENED']
147
+ | (typeof EVENTS)['EMAIL_CLICKED']
148
+ | (typeof EVENTS)['EMAIL_BOUNCED']
149
+ | (typeof EVENTS)['EMAIL_COMPLAINED'];
150
+ type _EmailEventsUseSharedShape = Expect<Equal<EventProperties[EmailLifecycleEvent], EmailEventProperties>>;
@@ -0,0 +1,39 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ ANALYTICS_SOURCES,
5
+ SchedulingAnalyticsKpiDTO,
6
+ } from './I_SchedulingAnalytics';
7
+
8
+ // AD2-1045 — the analytics partial-failure signal: a KPI response can name
9
+ // which sources were unavailable so a swallowed Zernio failure is never
10
+ // presented as real zeros.
11
+
12
+ test('ANALYTICS_SOURCES names the four analytics sources', () => {
13
+ assert.deepEqual(
14
+ [...ANALYTICS_SOURCES],
15
+ ['daily-metrics', 'follower-stats', 'content-decay', 'top-posts'],
16
+ );
17
+ });
18
+
19
+ test('SchedulingAnalyticsKpiDTO carries failedSources when provided', () => {
20
+ const kpi = new SchedulingAnalyticsKpiDTO({
21
+ impressions: 10,
22
+ reach: 5,
23
+ engagementRate: 1.2,
24
+ followerGrowth: 3,
25
+ failedSources: ['follower-stats'],
26
+ });
27
+ assert.deepEqual(kpi.failedSources, ['follower-stats']);
28
+ });
29
+
30
+ test('failedSources is optional and absent by default (backward compatible)', () => {
31
+ const kpi = new SchedulingAnalyticsKpiDTO({
32
+ impressions: 10,
33
+ reach: 5,
34
+ engagementRate: 1.2,
35
+ followerGrowth: 3,
36
+ });
37
+ assert.equal(kpi.failedSources, undefined);
38
+ assert.equal('failedSources' in JSON.parse(JSON.stringify(kpi)), false);
39
+ });
@@ -5,6 +5,21 @@
5
5
  * per-post timeline snapshots, content decay windows, and follower stats.
6
6
  */
7
7
 
8
+ // ── Analytics sources (AD2-1045) ──────────────────────────────────────────────
9
+
10
+ /**
11
+ * The upstream sources an analytics response aggregates. Used by
12
+ * `failedSources` to name which of them were unavailable.
13
+ */
14
+ export const ANALYTICS_SOURCES = [
15
+ 'daily-metrics',
16
+ 'follower-stats',
17
+ 'content-decay',
18
+ 'top-posts',
19
+ ] as const;
20
+
21
+ export type AnalyticsSource = (typeof ANALYTICS_SOURCES)[number];
22
+
8
23
  // ── SchedulingAnalyticsKpiDTO ─────────────────────────────────────────────────
9
24
 
10
25
  /**
@@ -16,12 +31,22 @@ export class SchedulingAnalyticsKpiDTO {
16
31
  reach: number;
17
32
  engagementRate: number;
18
33
  followerGrowth: number;
34
+ /**
35
+ * Sources that were unavailable when this response was assembled
36
+ * (AD2-1045). Absent/empty = all sources healthy. When present, the
37
+ * numeric fields contain only data from the healthy sources — a partial
38
+ * outage must never read as real zeros.
39
+ */
40
+ failedSources?: AnalyticsSource[];
19
41
 
20
42
  constructor(data: SchedulingAnalyticsKpiDTO) {
21
43
  this.impressions = data.impressions;
22
44
  this.reach = data.reach;
23
45
  this.engagementRate = data.engagementRate;
24
46
  this.followerGrowth = data.followerGrowth;
47
+ if (data.failedSources !== undefined) {
48
+ this.failedSources = data.failedSources;
49
+ }
25
50
  }
26
51
  }
27
52
 
@@ -131,7 +156,11 @@ export class SchedulingPostTimelineEntryDTO {
131
156
  * Returned by GET /social/analytics/content-decay.
132
157
  */
133
158
  export class SchedulingContentDecayDTO {
134
- window: '1h' | '6h' | '24h' | '7d';
159
+ /**
160
+ * Zernio now returns aggregate bucket labels (e.g. "0-6h", "6-12h"), not
161
+ * the old fixed windows — widened accordingly (AD2-1061, 2026-07-03).
162
+ */
163
+ window: string;
135
164
  platform: string;
136
165
  /** Percentage of peak engagement remaining at this window */
137
166
  pct: number;