diff --git a/app/(site)/art/site--spot-launch-directories-cover.html b/app/(site)/art/site--spot-launch-directories-cover.html index 969ab9abc..bfd4b57d5 100644 --- a/app/(site)/art/site--spot-launch-directories-cover.html +++ b/app/(site)/art/site--spot-launch-directories-cover.html @@ -64,7 +64,7 @@
- stack127 + stack127

Find the right place to launch your product

Compare cost, review time, link type, and who can submit.

diff --git a/app/ui/site/brand-wordmark.tsx b/app/ui/site/brand-wordmark.tsx index e43b251a2..10350cce1 100644 --- a/app/ui/site/brand-wordmark.tsx +++ b/app/ui/site/brand-wordmark.tsx @@ -15,19 +15,19 @@ export function BrandWordmark({ className }: BrandWordmarkProps) { className={`${styles.image} ${styles.onDark}`} decoding="async" fetchPriority="high" - height="282" + height="790" loading="eager" - src="/logos/stack127-wordmark-on-dark.png" - width="1451" + src="/logos/stack127-wordmark-on-dark.svg" + width="3438" /> ); diff --git a/lib/data-api/ad-networks/core.ts b/lib/data-api/ad-networks/core.ts index fdd0fa87c..c590c3049 100644 --- a/lib/data-api/ad-networks/core.ts +++ b/lib/data-api/ad-networks/core.ts @@ -1,3 +1,4 @@ +import { brandKit } from '../brand.ts'; import { createHash } from 'node:crypto'; import { domainToASCII } from 'node:url'; import { adNetworkFacts } from '../../../app/(site)/(directories)/ad-networks/network-facts.ts'; @@ -133,9 +134,10 @@ export function boundResults(data: Record,cap: number) { sets.forEach((set,i)=>{if(set.parent[set.key].length>counts[i]){set.parent[set.key]=set.parent[set.key].slice(0,counts[i]);set.parent.truncated=true;truncatedSets.push(set.path);}}); return {row_count:cap-left,truncated_sets:truncatedSets}; } -export type DataAnswer = { as_of: unknown; queried_at: string; definitions: typeof definitions; caveats: string[]; filters: Record; url: string; data: Record; limits: { max_rows: number; row_count:number; truncated: boolean; truncated_sets:string[] } }; +export type DataAnswer = { as_of: unknown; queried_at: string; definitions: Record; caveats: string[]; filters: Record; url: string; data: Record; limits: { max_rows: number; row_count:number; truncated: boolean; truncated_sets:string[] } }; export async function queryTool(db: Reader,name: ToolName,f: Record,cap: number): Promise> { switch(name) { + case 'get_brand_kit': return brandKit; case 'ad_networks_overview': { const stats=(await db.query('select * from dataset_summary'))[0]; const totals=(await db.query(`select count(distinct publisher_id)::int as listed,count(distinct publisher_id) filter(where relationship_type='primary_or_exclusive_manager')::int as managed from ad_network_publisher_relationships r where ${listed}`))[0]; @@ -198,6 +200,13 @@ export async function executeTool(name: ToolName,input: unknown,transaction?: ty if (!toolNames.includes(name)) throw new ApiError(404,'not_found','Unknown tool.'); if (name==='run_readonly_sql' && !sqlEnabled()) throw new ApiError(404,'not_found','Not found.'); const f=validateInput(name,input),cap=rowLimit(); + if(name==='get_brand_kit') return { + as_of:null,queried_at:new Date().toISOString(), + definitions:{as_of:'Brand assets have no dataset freshness date. Use as_of from the data answer in the source line.'}, + caveats:['Use the data tool answer for its citation URL, as_of date, definitions and caveats.'], + filters:f,url:sourceUrl,data:brandKit, + limits:{max_rows:cap,row_count:0,truncated:false,truncated_sets:[]}, + }; return (transaction ?? (name==='run_readonly_sql' ? sqlReadTransaction : readTransaction))(async db=>{ const freshness=(await db.query('select * from dataset_summary'))[0]; const data=await queryTool(db,name,f,cap); diff --git a/lib/data-api/ad-networks/schemas.ts b/lib/data-api/ad-networks/schemas.ts index 3b11056a7..a35d6aab4 100644 --- a/lib/data-api/ad-networks/schemas.ts +++ b/lib/data-api/ad-networks/schemas.ts @@ -15,6 +15,7 @@ export const filtersShape = { status: z.enum(['confirmed','stale']).optional().describe('Further restrict listed status. Ended relationships are available only in lookup_site and movements.'), }; export const toolSchemas = { + get_brand_kit: z.object({}).strict(), ad_networks_overview: z.object({}).strict(), list_ad_networks: z.object({ max_minimum_traffic: z.number().int().nonnegative().optional().describe('Maximum published monthly minimum. Unknown minimums are excluded; units differ, use traffic_unit. Example: 50000.'), diff --git a/lib/data-api/brand-assets.generated.ts b/lib/data-api/brand-assets.generated.ts new file mode 100644 index 000000000..1417c3d64 --- /dev/null +++ b/lib/data-api/brand-assets.generated.ts @@ -0,0 +1,91 @@ +// Generated by node scripts/stack127/brand/build-api-kit.mjs. SVGs are bundled with the server. +export const brandAssets = { + "colors": { + "dark": { + "background": "#101010", + "surface": "#191919", + "text": "#f2f2ee", + "muted_text": "#9d9c95", + "border": "rgba(242, 242, 238, 0.14)", + "accent": "#bafc3c", + "accent_text": "#bafc3c", + "accent_steps": [ + "#bafc3c", + "rgba(186, 252, 60, 0.62)", + "rgba(186, 252, 60, 0.3)" + ], + "neutral_steps": [ + "rgba(242, 242, 238, 0.42)", + "rgba(242, 242, 238, 0.24)", + "rgba(242, 242, 238, 0.12)" + ], + "categorical": { + "chartreuse": "#bafc3c", + "cyan": "#45cbd3", + "blue": "#7691f2", + "yellow": "#efb84f", + "red": "#ed746c", + "violet": "#aa82df", + "green": "#65bd76" + } + }, + "light": { + "background": "#f4f2ec", + "surface": "#fdfcf9", + "text": "#141414", + "muted_text": "#5f5d57", + "border": "rgba(20, 20, 20, 0.14)", + "accent": "#bafc3c", + "accent_text": "#3a7000", + "accent_steps": [ + "#3a7000", + "rgba(58, 112, 0, 0.55)", + "rgba(58, 112, 0, 0.26)" + ], + "neutral_steps": [ + "rgba(20, 20, 20, 0.42)", + "rgba(20, 20, 20, 0.24)", + "rgba(20, 20, 20, 0.12)" + ], + "categorical": { + "chartreuse": "#8fc51f", + "cyan": "#1596a3", + "blue": "#4f6fd1", + "yellow": "#c78a14", + "red": "#c8554f", + "violet": "#865fc2", + "green": "#3f8f52" + } + } + }, + "fonts": { + "titles": { + "name": "Space Grotesk", + "css": "\"Space Grotesk\", ui-sans-serif, -apple-system, BlinkMacSystemFont, \"Segoe UI\", system-ui, sans-serif" + }, + "body": { + "name": "Inter", + "css": "\"Inter\", ui-sans-serif, -apple-system, BlinkMacSystemFont, \"Segoe UI\", system-ui, sans-serif" + }, + "numbers": { + "name": "IBM Plex Mono", + "css": "\"IBM Plex Mono\", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", monospace" + } + }, + "logo": { + "dark": { + "wordmark_svg": "", + "wordmark_url": "https://stack127.com/logos/stack127-wordmark-on-dark.svg", + "mark_svg": "", + "mark_url": "https://stack127.com/logos/stack127-mark-on-dark.svg", + "mark_png_url": "https://stack127.com/logos/stack127-mark-on-dark.png" + }, + "light": { + "wordmark_svg": "", + "wordmark_url": "https://stack127.com/logos/stack127-wordmark-on-light.svg", + "mark_svg": "", + "mark_url": "https://stack127.com/logos/stack127-mark-on-light.svg", + "mark_png_url": "https://stack127.com/logos/stack127-mark-on-light.png" + } + } +} as const; diff --git a/lib/data-api/brand.ts b/lib/data-api/brand.ts new file mode 100644 index 000000000..79813fded --- /dev/null +++ b/lib/data-api/brand.ts @@ -0,0 +1,18 @@ +import { brandAssets } from './brand-assets.generated.ts'; + +export const brandKit = { + name: 'stack127', + credit: 'Ad network data from stack127', + source_line: 'Source: stack127.com/ad-networks · data as of ', + url: 'https://stack127.com/ad-networks', + default_theme: 'dark', + ...brandAssets, + chart_rules: [ + 'Highlight the main series with the accent; use neutral steps for other series.', + 'Use the lightest neutral step for unknown or missing values.', + 'Use no gradients, shadows or 3D.', + 'Label units and the data answer\'s as_of date. Use its more specific page URL in the source line when available.', + 'Place a small logo in a corner. Never stretch or recolor it; keep the #bafc3c dot.', + ], + tone: 'Plain, direct, no hype.', +}; diff --git a/lib/data-api/mcp.ts b/lib/data-api/mcp.ts index cbf9aa883..024dae409 100644 --- a/lib/data-api/mcp.ts +++ b/lib/data-api/mcp.ts @@ -8,6 +8,7 @@ import { toolNames, toolSchemas } from './ad-networks/schemas.ts'; import type { ToolName } from './ad-networks/schemas.ts'; import { sqlSchema } from './ad-networks/sql.ts'; const descriptions: Record = { + get_brand_kit:'For charts, images, tables and shared visuals: call once per visual task for stack127 colors, fonts, inline SVG logos and credit rules. Not needed for data questions. Input {}.', ad_networks_overview:'Start here before researching stack127 ad network data. Returns coverage totals, freshness, all definitions/caveats, allowed filters and a tool guide. Counts observed sites, not the whole market.', list_ad_networks:'Use to choose networks by joining rules or compare listed/managed counts and payment terms. Returns sourced public facts with checkedAt, median RDAP domain age and measurement bases. Traffic units and alternative joining rules are not comparable without reading the facts.', get_ad_network:'Use for a full profile of one network slug. Returns sourced joining/payment facts, confirmed/stale/listed/managed counts, age/year/niche/size/platform distributions, co-listed networks and bounded newest-first daily snapshots. Missing days are unknown; snapshots are listed counts.', @@ -34,7 +35,7 @@ export async function handleMcp(request: Request,secret?: string): Promise", using the data answer’s as_of date and its more specific page URL when available. Use the brand colors and dark theme by default.'}); for(const name of toolNames.filter(n=>n!=='run_readonly_sql'||sqlEnabled())) { server.registerTool(name,{ title:name.split('_').map(w=>w[0].toUpperCase()+w.slice(1)).join(' '),description:descriptions[name], diff --git a/public/brand/originals/stack127-wordmark-hybrid-on-dark.svg b/public/brand/originals/stack127-wordmark-hybrid-on-dark.svg new file mode 100644 index 000000000..17b081257 --- /dev/null +++ b/public/brand/originals/stack127-wordmark-hybrid-on-dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/public/brand/originals/stack127-wordmark-hybrid-on-light.svg b/public/brand/originals/stack127-wordmark-hybrid-on-light.svg new file mode 100644 index 000000000..d6546a167 --- /dev/null +++ b/public/brand/originals/stack127-wordmark-hybrid-on-light.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/public/logos/stack127-mark-on-dark.svg b/public/logos/stack127-mark-on-dark.svg new file mode 100644 index 000000000..9027deccb --- /dev/null +++ b/public/logos/stack127-mark-on-dark.svg @@ -0,0 +1 @@ + diff --git a/public/logos/stack127-mark-on-light.svg b/public/logos/stack127-mark-on-light.svg new file mode 100644 index 000000000..776f83787 --- /dev/null +++ b/public/logos/stack127-mark-on-light.svg @@ -0,0 +1 @@ + diff --git a/public/logos/stack127-wordmark-on-dark.png b/public/logos/stack127-wordmark-on-dark.png deleted file mode 100644 index 8a65b794d..000000000 Binary files a/public/logos/stack127-wordmark-on-dark.png and /dev/null differ diff --git a/public/logos/stack127-wordmark-on-dark.svg b/public/logos/stack127-wordmark-on-dark.svg new file mode 100644 index 000000000..4b4a1903f --- /dev/null +++ b/public/logos/stack127-wordmark-on-dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/public/logos/stack127-wordmark-on-light.png b/public/logos/stack127-wordmark-on-light.png deleted file mode 100644 index 5c1a5a363..000000000 Binary files a/public/logos/stack127-wordmark-on-light.png and /dev/null differ diff --git a/public/logos/stack127-wordmark-on-light.svg b/public/logos/stack127-wordmark-on-light.svg new file mode 100644 index 000000000..330216e07 --- /dev/null +++ b/public/logos/stack127-wordmark-on-light.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/scripts/stack127/brand/build-api-kit.mjs b/scripts/stack127/brand/build-api-kit.mjs new file mode 100644 index 000000000..d5ccebdcf --- /dev/null +++ b/scripts/stack127/brand/build-api-kit.mjs @@ -0,0 +1,74 @@ +import {readFileSync, writeFileSync} from 'node:fs'; + +// Keep SVG geometry exact while reducing repeated absolute coordinates. +function compactPath(path) { + const tokens=path.match(/[a-z]|-?(?:\d*\.)?\d+/gi); + const arity={M:2,L:2,H:1,V:1,Q:4,C:6,Z:0}; + let i=0,x=0,y=0,sx=0,sy=0,command='',previous='',out='',controlX=0,controlY=0,lastCommand=''; + const numbers=values=>values.join(' ').replace(/ -/g,'-'); + while(iv-(command==='H'?x:command==='V'?y:j%2===0?x:y)); + const absoluteText=(previous===command&&command!=='M'?'':command)+numbers(values); + const lower=command.toLowerCase(); + const relativeText=(previous===lower&&command!=='M'?'':lower)+numbers(relative); + const candidates=[{text:absoluteText,cmd:command},{text:relativeText,cmd:lower}]; + const add=(cmd,values)=>candidates.push({text:(previous===cmd?'':cmd)+numbers(values),cmd}); + if(command==='L') { + if(values[0]===x){add('V',[values[1]]);add('v',[values[1]-y]);} + if(values[1]===y){add('H',[values[0]]);add('h',[values[0]-x]);} + } + if(command==='C') { + const dx=values[4]-x,dy=values[5]-y,length=dx*dx+dy*dy; + const straight=[0,2].every(j=> (values[j]-x)*dy===(values[j+1]-y)*dx + && (values[j]-x)*dx+(values[j+1]-y)*dy>=0 + && (values[j]-x)*dx+(values[j+1]-y)*dy<=length); + if(straight){add('L',values.slice(4));add('l',relative.slice(4));} + } + if(command==='C'&&lastCommand==='C'&&values[0]===2*x-controlX&&values[1]===2*y-controlY) { + add('S',values.slice(2));add('s',relative.slice(2)); + } + if(command==='Q'&&lastCommand==='Q'&&values[0]===2*x-controlX&&values[1]===2*y-controlY) { + add('T',values.slice(2));add('t',relative.slice(2)); + } + const best=candidates.sort((a,b)=>a.text.length-b.text.length)[0]; + const part=best.text; + if(command==='C'||command==='Q'){controlX=values.at(-4);controlY=values.at(-3);} + lastCommand=best.cmd.toUpperCase()==='S'?'C':best.cmd.toUpperCase()==='T'?'Q':best.cmd.toUpperCase(); + if(out&&/^\d/.test(part)&&/[\d.]$/.test(out))out+=' '; + out+=part;previous=best.cmd; + if(command==='H')x=values[0];else if(command==='V')y=values[0]; + else {x=values.at(-2);y=values.at(-1);} + if(command==='M'){sx=x;sy=y;command='L';} + } + return out; +} +const read=path=>readFileSync(path,'utf8'); +const compact=svg=>svg.replace(/\bd="([^"]+)"/g,(_,d)=>`d="${compactPath(d)}"`).replace(/\n/g,''); +const logos={}; +for(const theme of ['dark','light']) { + const wordmark=read(`public/logos/stack127-wordmark-on-${theme}.svg`); + const path=[...wordmark.matchAll(/]*\/>/g)].at(-1)[0]; + const circle=wordmark.match(/]*\/>/)[0]; + // The 127 path spans x=7598..11538, y=72..2146. Include a small clear margin. + const mark=`${path}${circle}`; + writeFileSync(`public/logos/stack127-mark-on-${theme}.svg`,compact(mark)+'\n'); + logos[theme]={wordmark_svg:compact(wordmark),wordmark_url:`https://stack127.com/logos/stack127-wordmark-on-${theme}.svg`,mark_svg:compact(mark),mark_url:`https://stack127.com/logos/stack127-mark-on-${theme}.svg`,mark_png_url:`https://stack127.com/logos/stack127-mark-on-${theme}.png`}; +} +const globals=read('app/globals.css'),viz=read('app/ui/ad-networks/viz/viz.module.css'); +const declarations=block=>Object.fromEntries([...block.matchAll(/(--[\w-]+):\s*([^;]+);/g)].map(m=>[m[1],m[2].trim()])); +const light=declarations(globals.split('[data-theme="dark"]')[0]); +const dark={...light,...declarations(globals.split('[data-theme="dark"]')[1].split('}')[0])}; +const vizLight=declarations(viz.split(':global')[0]),vizDark=declarations(viz.split(':global')[1].split('}')[0]); +const colors={}; +for(const [theme,tokens,steps] of [['dark',dark,vizDark],['light',light,vizLight]]) { + const resolve=value=>value.replace(/var\((--[\w-]+)\)/g,(_,key)=>tokens[key]); + colors[theme]={...Object.fromEntries(Object.entries({background:'bg',surface:'surface',text:'text',muted_text:'text-muted',border:'border',accent:'accent',accent_text:'accent-text'}).map(([key,value])=>[key,tokens['--color-'+value]])),accent_steps:['ac','ac2','ac3'].map(key=>resolve(steps['--viz-'+key])),neutral_steps:['n1','n2','n3'].map(key=>steps['--viz-'+key]),categorical:Object.fromEntries(Object.entries(tokens).filter(([key])=>key.startsWith('--color-data-')).map(([key,value])=>[key.replace('--color-data-',''),value]))}; +} +const fonts=Object.fromEntries([['titles','display','Space Grotesk'],['body','sans','Inter'],['numbers','mono','IBM Plex Mono']].map(([key,token,name])=>[key,{name,css:light['--font-'+token].replace(/var\([^)]+\)/,`"${name}"`).replace(/\s+/g,' ')}])); +writeFileSync('lib/data-api/brand-assets.generated.ts','// Generated by node scripts/stack127/brand/build-api-kit.mjs. SVGs are bundled with the server.\nexport const brandAssets = '+JSON.stringify({colors,fonts,logo:logos},null,2)+' as const;\n'); +console.log('Embedded brand assets:',Buffer.byteLength(JSON.stringify({colors,fonts,logo:logos})),'bytes'); diff --git a/scripts/stack127/brand/build-assets.mjs b/scripts/stack127/brand/build-assets.mjs index 0e3d8d7b1..e375657a9 100644 --- a/scripts/stack127/brand/build-assets.mjs +++ b/scripts/stack127/brand/build-assets.mjs @@ -6,6 +6,7 @@ const root = process.cwd(); const originals = path.join(root, "public", "brand", "originals"); const logos = path.join(root, "public", "logos"); +// The wordmark itself ships as SVG (public/logos/*.svg). This PNG is only the source of the 127 mark and icons. const wordmarkSource = path.join(originals, "stack127-wordmark-approved.png"); const darkBackground = { r: 16, g: 16, b: 16 }; @@ -97,14 +98,10 @@ function icoFromPngs(entries) { async function main() { await mkdir(logos, { recursive: true }); - const wordmarkOnLight = await extractedLogo(wordmarkSource, darkInk); - const wordmarkOnDark = await extractedLogo(wordmarkSource, lightInk); const markOnLight = await extractedLogo(wordmarkSource, darkInk, 8, true); const markOnDark = await extractedLogo(wordmarkSource, lightInk, 8, true); await Promise.all([ - writeFile(path.join(logos, "stack127-wordmark-on-light.png"), wordmarkOnLight), - writeFile(path.join(logos, "stack127-wordmark-on-dark.png"), wordmarkOnDark), writeFile(path.join(logos, "stack127-mark-on-light.png"), markOnLight), writeFile(path.join(logos, "stack127-mark-on-dark.png"), markOnDark), ]); diff --git a/stack127_docs/brand-assets.md b/stack127_docs/brand-assets.md index 69a7f6d2f..1630246f6 100644 --- a/stack127_docs/brand-assets.md +++ b/stack127_docs/brand-assets.md @@ -1,10 +1,12 @@ # stack127 brand assets -The approved wordmark has a clear double-storey a, geometric 127 numerals, and a chartreuse dot. The header and footer share `app/ui/site/brand-wordmark.tsx`. +The wordmark has a lighter geometric "stack" (single-storey a, tall t and k, chosen November 2026), the original geometric 127 numerals, and a chartreuse dot. The header and footer share `app/ui/site/brand-wordmark.tsx`. ## Sources -`public/brand/originals/stack127-wordmark-approved.png` is the September 2026 design the owner picked. It's the current source for the wordmark and the cropped 127 mark. +`public/brand/originals/stack127-wordmark-hybrid-on-dark.svg` and `-on-light.svg` are the November 2026 wordmark the owner picked: a new "stack" with the unchanged 127. They are the source for the wordmark. The light original has a darker dot; the site keeps `#bafc3c` in both themes. + +`public/brand/originals/stack127-wordmark-approved.png` is the September 2026 design. It's still the source for the cropped 127 mark and the icons. The earlier `stack127-wordmark-original.png` and `stack127-mark-original.png` stay untouched in the same folder as historical sources. The brand tests still protect their checksums. @@ -12,15 +14,17 @@ The earlier `stack127-wordmark-original.png` and `stack127-mark-original.png` st | File | What it is | | --- | --- | -| `public/logos/stack127-wordmark-on-dark.png` | Off-white text on a transparent background, with the exact `#bafc3c` dot. | -| `public/logos/stack127-wordmark-on-light.png` | Black text on a transparent background, with the exact `#bafc3c` dot. | +| `public/logos/stack127-wordmark-on-dark.svg` | `#f2f2ee` text on a transparent background, with the exact `#bafc3c` dot. | +| `public/logos/stack127-wordmark-on-light.svg` | Black text on a transparent background, with the exact `#bafc3c` dot. | | Transparent and solid square mark variants in `public/logos/` | The 127 part of the approved design. | The favicon, Apple touch icon, Android icons, organization logo, and default social image all use the matching dark square mark. ## Regeneration -Run `npm run stack127:brand:build`. The builder: +The wordmark SVGs are the hybrid originals with path numbers rounded to whole units, the text color set per theme, and the dot set to `#bafc3c`. Edit them by hand from the originals; the builder doesn't touch them. + +Run `npm run stack127:brand:build` for the 127 mark and icons. The builder: 1. Pulls the foreground from the approved dark source. 2. Normalizes the ink and accent colors. @@ -29,4 +33,4 @@ Run `npm run stack127:brand:build`. The builder: The mark crop starts at the empty column between k and 1. That's x=1207 in the 1983-pixel-wide approved source. Update this crop if the source changes. -The wordmark files are 1451 by 282 pixels. When you replace the source, keep the intrinsic dimensions in `app/ui/site/brand-wordmark.tsx` in sync. +The wordmark SVG view box is 3438 by 790. When you replace it, keep the intrinsic dimensions in `app/ui/site/brand-wordmark.tsx` in sync. diff --git a/stack127_docs/mcp-and-api.md b/stack127_docs/mcp-and-api.md index f95e37eec..f18f08f5c 100644 --- a/stack127_docs/mcp-and-api.md +++ b/stack127_docs/mcp-and-api.md @@ -8,7 +8,7 @@ This is the owner's research API. It reads the same ad network dataset and sourc The official TypeScript SDK is pinned to `@modelcontextprotocol/sdk` **1.32.2**. npm reports publication on **2026-10-23**, 22 days before the **2026-11-14** review. Its [SDK documentation](https://github.com/modelcontextprotocol/typescript-sdk) describes Streamable HTTP; the installed package includes the stateless and JSON-response examples. The server uses the SDK's Web Standards transport, no session IDs, and JSON responses. GET event streams and resumable sessions are not offered. There is no scheduler or new service. -Every successful answer carries `as_of`, `queried_at`, definitions, caveats, normalized filters, a stack127 citation URL, data, and limits. `as_of` is the latest stored successful site check, relationship event, or daily snapshot capture, not the request time and not a guarantee that every row is that fresh. Overview also gives oldest/newest successful site checks. CrUX's import month is not retained in these serving tables; answers say it is unknown. Factual joining/payment sources have their own `checkedAt` date. +Every successful answer carries `as_of`, `queried_at`, definitions, caveats, normalized filters, a stack127 citation URL, data, and limits. For data tools, `as_of` is the latest stored successful site check, relationship event, or daily snapshot capture, not the request time and not a guarantee that every row is that fresh. Overview also gives oldest/newest successful site checks. CrUX's import month is not retained in these serving tables; answers say it is unknown. Factual joining/payment sources have their own `checkedAt` date. ## Tools and REST routes @@ -16,6 +16,7 @@ For every row below, the REST route is **POST `/api/v1/ad-networks/`* | Tool / REST suffix | Use it for | | --- | --- | +| `get_brand_kit` | Colors for both themes, font stacks, inline SVG wordmarks and marks, public logo URLs, credit and chart rules. Input `{}`. Call once per visual task; skip for data questions. | | `ad_networks_overview` | Start here: sites checked, distinct listed/managed totals, freshness, measurement, definitions, caveats, allowed values, tool guide. Input `{}`. | | `list_ad_networks` | Counts, domain-age median and measured base, sourced joining/payment facts and checked dates. Filter published numeric minimum, traffic unit, or public numeric rule; sort by listed, managed, name or minimum traffic. | | `get_ad_network` | One `network` slug, facts/sources, counts, domain-age buckets/median, registration years, niche/size/platform mix, co-listing and daily listed snapshots. | @@ -32,6 +33,16 @@ Listed means confirmed or stale. Managed means a listed `primary_or_exclusive_ma Movement answers are not reliable growth/churn series. History begins around October 19, 2026. Homepage/checker faults were repaired November 13/14; the conservative repair search begins September 28. Some false departures were repaired; others remain uncertain. Missing history days are unknown, not zero. See [the production runbook](../content_docs/ad-networks/production-runbook.md) and [growth status](brand-growth/status.md). +## Brand kit + +Call `get_brand_kit` before making a chart, image or table for sharing. Use dark mode by default, title it by what it shows, and add "Ad network data from stack127" with a small logo. Include `Source: stack127.com/ad-networks · data as of `. Use the data answer's `as_of` date and its more specific page URL when available. Keep its definitions and caveats with the visual. + +The kit returns `as_of: null` because brand assets have no dataset freshness date. It opens no data transaction. Authentication and database-backed rate limits still apply to both front doors. + +Colors and font stacks come from `app/globals.css` and the ad network visualization tokens. SVG text is embedded in `lib/data-api/brand-assets.generated.ts`, so standalone server bundles need no runtime file reads. After changing the source tokens or wordmarks, run `node scripts/stack127/brand/build-api-kit.mjs` to refresh the embedded assets and mark SVGs. + +Connected clients must refresh their tool list to see `get_brand_kit` and the updated server instructions. + ## Authentication and future public plans `authenticate(request) -> Principal` is the replacement point for future user API keys/OAuth and paid plans. Today both credentials map to `private-owner`, plan `private`. `PUBLIC_MODE` is a hardcoded false constant. There is no anonymous fallback, public switch, signup, billing endpoint, or public SQL access. @@ -245,7 +256,7 @@ npm run test:data-api:routing npm run test:data-api:development ``` -The development test uses `.env.local` and is skipped unless the effective connection exactly matches the pinned managed development project and its postgres pooler login. It starts only a temporary loopback HTTP adapter for the actual Next route handlers, and calls tools/list plus all eight fixed tools using the official SDK client. It tests REST parity, public count parity, grouping, cursor continuation, projection fields, read-only transactions, timeout, row limits, off switches, SQL hidden even with its flag on, and concurrent minute/day limits. Focused tests also run the real proxy plus route handlers for all methods and status/error paths, compare wrong-secret and unknown-API 404s, reject nested collections and check MCP text byte limits. It does not run a Next build or connect the owner's actual Claude/ChatGPT accounts. +The development test uses `.env.local` and is skipped unless the effective connection exactly matches the pinned managed development project and its postgres pooler login. It starts only a temporary loopback HTTP adapter for the actual Next route handlers, and calls tools/list plus all fixed tools using the official SDK client. It tests REST parity, public count parity, grouping, cursor continuation, projection fields, read-only transactions, timeout, row limits, off switches, SQL hidden even with its flag on, and concurrent minute/day limits. Focused tests also run the real proxy plus route handlers for all methods and status/error paths, compare wrong-secret and unknown-API 404s, reject nested collections and check MCP text byte limits. It does not run a Next build or connect the owner's actual Claude/ChatGPT accounts. To add a tool, add its strict described schema in `schemas.ts`, its read-only query in `core.ts`, and its AI-facing description in `mcp.ts`. The generic REST route automatically exposes that same tool name. Add safe projections to a reviewed migration only if new fields are needed. Keep output metadata and the shared result-row budget. Add focused tests and extend the SDK development test; run quality. Release through the normal reviewer/PR path, then refresh connected tool lists. No separate MCP server deployment is needed. diff --git a/tests/data-api/core.test.mjs b/tests/data-api/core.test.mjs index 14eb5b522..035608a01 100644 --- a/tests/data-api/core.test.mjs +++ b/tests/data-api/core.test.mjs @@ -119,3 +119,58 @@ test('future SQL uses only its dedicated pool and read-only transaction, includi globalThis.stack127DataApiPool=previous.app;globalThis.stack127DataSqlPool=previous.sql; } }); + +test('brand kit has theme colors and inline logos, stays small and never opens a data transaction',async()=>{ + const answer=await executeTool('get_brand_kit',{},()=>assert.fail('brand kit opened a transaction')); + assert.equal(answer.as_of,null); + assert.equal(answer.data.colors.dark.accent,'#bafc3c'); + assert.equal(answer.data.colors.light.accent_text,'#3a7000'); + assert.equal(answer.data.colors.dark.categorical.cyan,'#45cbd3'); + assert.equal(answer.data.colors.light.categorical.cyan,'#1596a3'); + for(const theme of ['dark','light']) { + assert.match(answer.data.logo[theme].wordmark_svg,/{ + const previousPool=globalThis.stack127DataApiPool; + const keys=['STACK127_MCP_ENABLED','STACK127_REST_ENABLED','STACK127_MCP_TOKEN']; + const previousEnv=Object.fromEntries(keys.map(key=>[key,process.env[key]])); + let rates=0,limited=false; + globalThis.stack127DataApiPool={ + connect:()=>assert.fail('brand kit opened a DB transaction'), + query:async sql=>{assert.match(sql,/insert into stack127_api.rate_limits/);rates++;return {rowCount:limited?0:1};}, + }; + Object.assign(process.env,{STACK127_MCP_ENABLED:'true',STACK127_REST_ENABLED:'true',STACK127_MCP_TOKEN:token}); + const headers={authorization:'Bearer '+token,'content-type':'application/json',accept:'application/json, text/event-stream'}; + const mcp=async(method,params)=>{ + const response=await handleMcp(new Request('http://localhost/api/mcp',{method:'POST',headers,body:JSON.stringify({jsonrpc:'2.0',id:1,method,params})})); + assert.equal(response.status,200);return response.json(); + }; + try { + const list=await mcp('tools/list',{}); + const tool=list.result.tools.find(t=>t.name==='get_brand_kit'); + assert.ok(tool);assert.equal(tool.annotations.readOnlyHint,true);assert.ok(tool.outputSchema); + const called=await mcp('tools/call',{name:'get_brand_kit',arguments:{}}); + assert.ok(!called.result.isError); + const response=await handleRest(new Request('http://localhost',{method:'POST',headers,body:'{}'}),'get_brand_kit'); + assert.equal(response.status,200);assert.equal(response.headers.get('cache-control'),'no-store'); + const rest=await response.json(); + assert.deepEqual(rest.data,called.result.structuredContent.data); + assert.equal(rates,3); + assert.equal((await handleRest(new Request('http://localhost',{method:'POST',body:'{}'}),'get_brand_kit')).status,401); + assert.equal((await handleMcp(new Request('http://localhost',{method:'POST',body:'{}'}))).status,401); + limited=true; + assert.equal((await handleRest(new Request('http://localhost',{method:'POST',headers,body:'{}'}),'get_brand_kit')).status,429); + assert.equal((await handleMcp(new Request('http://localhost',{method:'POST',headers,body:'{}'}))).status,429); + } finally { + globalThis.stack127DataApiPool=previousPool; + for(const key of keys){if(previousEnv[key]===undefined)delete process.env[key];else process.env[key]=previousEnv[key];} + } +}); diff --git a/tests/data-api/integration.test.mjs b/tests/data-api/integration.test.mjs index df7a65496..10e861d1f 100644 --- a/tests/data-api/integration.test.mjs +++ b/tests/data-api/integration.test.mjs @@ -45,7 +45,7 @@ test('development: SDK Streamable HTTP calls every tool, REST parity, public-cou const {tools}=await client.listTools();assert.deepEqual(tools.map(t=>t.name).sort(),toolNames.filter(n=>n!=='run_readonly_sql').toSorted()); for(const t of tools){assert.equal(t.annotations.readOnlyHint,true);assert.ok(t.title&&t.description&&t.outputSchema);} const sample=(await executeTool('find_sites',{limit:1})).data.sites[0];assert.ok(sample); - const inputs={ad_networks_overview:{},list_ad_networks:{},get_ad_network:{network:'mediavine'},compare_ad_networks:{networks:['mediavine','ezoic'],dimension:'niche'},count_sites:{networks:['mediavine'],relationship:'managed'},find_sites:{limit:2},lookup_site:{domain:sample.domain},network_movements:{from:'2026-10-19',to:'2026-11-14'},run_readonly_sql:{sql:'select slug,name from stack127_data.ad_networks order by slug'}}; + const inputs={get_brand_kit:{},ad_networks_overview:{},list_ad_networks:{},get_ad_network:{network:'mediavine'},compare_ad_networks:{networks:['mediavine','ezoic'],dimension:'niche'},count_sites:{networks:['mediavine'],relationship:'managed'},find_sites:{limit:2},lookup_site:{domain:sample.domain},network_movements:{from:'2026-10-19',to:'2026-11-14'},run_readonly_sql:{sql:'select slug,name from stack127_data.ad_networks order by slug'}}; for(const name of toolNames.filter(n=>n!=='run_readonly_sql')){const result=await client.callTool({name,arguments:inputs[name]});assert.ok(!result.isError,`${name}: ${JSON.stringify(result.content)}`);const answer=result.structuredContent;assert.ok(answer.definitions&&answer.caveats&&answer.url);assert.ok('as_of' in answer);assert.deepEqual(JSON.parse(result.content[0].text),answer);assert.ok(!JSON.stringify(answer).includes('publisher_name'));console.log('SDK tool passed:',name);} const response=await fetch(base+'/api/v1/ad-networks/count_sites',{method:'POST',headers:{authorization:'Bearer '+process.env.STACK127_MCP_TOKEN,'content-type':'application/json'},body:JSON.stringify(inputs.count_sites)}); assert.equal(response.status,200);assert.equal(response.headers.get('cache-control'),'no-store');const rest=await response.json();