@port60/template-kit 0.11.0 → 0.13.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.
package/bin/cli.mjs CHANGED
@@ -10,6 +10,8 @@ import { model } from '../src/commands/model.mjs';
10
10
  import { content } from '../src/commands/content.mjs';
11
11
  import { upgrade } from '../src/commands/upgrade.mjs';
12
12
  import { refresh } from '../src/commands/refresh.mjs';
13
+ import { login, logout, whoami } from '../src/commands/auth.mjs';
14
+ import { publish } from '../src/commands/publish.mjs';
13
15
 
14
16
  function parseArgs(argv) {
15
17
  const args = { _: [] };
@@ -59,6 +61,18 @@ switch (command) {
59
61
  case 'package':
60
62
  await packageCmd(args);
61
63
  break;
64
+ case 'login':
65
+ await login(args);
66
+ break;
67
+ case 'logout':
68
+ await logout(args);
69
+ break;
70
+ case 'whoami':
71
+ await whoami(args);
72
+ break;
73
+ case 'publish':
74
+ await publish(args);
75
+ break;
62
76
  default:
63
77
  console.log(`@port60/template-kit, build Port60 site templates locally
64
78
 
@@ -73,6 +87,12 @@ usage:
73
87
  p60-template-kit package [dir] validate + build the uploadable zip
74
88
  p60-template-kit upgrade [dir] move an existing template to the latest kit
75
89
  and contract, re-brief, re-validate
90
+ p60-template-kit login sign in from the terminal (the browser does the
91
+ actual sign-in; the code appears here)
92
+ p60-template-kit whoami who is signed in, and which studio workspace
93
+ p60-template-kit logout sign this machine out and revoke its token
94
+ p60-template-kit publish [dir] [--no-submit] validate, package, upload to your studio and
95
+ submit for review, in one command
76
96
 
77
97
  AI agents: the scaffold's AGENTS.md is your briefing; iterate with \`validate --json\`.
78
98
  Docs: https://developers.port60.com (agents: /llms-full.txt)`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@port60/template-kit",
3
- "version": "0.11.0",
4
- "description": "Build Port60 site templates locally: scaffold, live-preview, validate against the platform contract, and package for studio upload. AI-agent ready — every scaffold ships AGENTS.md and validate emits machine-readable JSON.",
3
+ "version": "0.13.0",
4
+ "description": "Build Port60 site templates locally: scaffold, live-preview, validate against the platform contract, and package for studio upload. AI-agent ready \u2014 every scaffold ships AGENTS.md and validate emits machine-readable JSON.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "p60-template-kit": "bin/cli.mjs"
@@ -12,6 +12,7 @@
12
12
  "starter"
13
13
  ],
14
14
  "scripts": {
15
+ "sync-vendor": "node scripts/sync-vendor.mjs",
15
16
  "test": "node --test 'tests/*.test.mjs'"
16
17
  },
17
18
  "dependencies": {
@@ -22,12 +23,5 @@
22
23
  "repository": {
23
24
  "type": "git",
24
25
  "url": "git+https://github.com/Port60Labs/template-kit.git"
25
- },
26
- "homepage": "https://developers.port60.com",
27
- "bugs": {
28
- "url": "https://github.com/Port60Labs/template-kit/issues"
29
- },
30
- "publishConfig": {
31
- "access": "public"
32
26
  }
33
27
  }
@@ -0,0 +1,102 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import {
3
+ accessToken, api, apiBase, authBase, authRealm, clearCredentials, decodeJwtPayload,
4
+ deviceLogin, loadCredentials, oauthClientId, requireCredentials, revoke, saveCredentials
5
+ } from '../lib/auth.mjs';
6
+
7
+ /**
8
+ * `login` / `logout` / `whoami` (developer CLI stream A). Login runs the device flow, then finds
9
+ * the developer's STUDIO workspace: memberships are product-blind, so the studio is discovered by
10
+ * probing the studio surface itself (the gateway's entitlement gate refuses non-studio workspaces,
11
+ * which is exactly the discriminator). One membership skips the ceremony; ambiguity asks once and
12
+ * the choice is remembered.
13
+ */
14
+
15
+ async function studioProbe(creds, token, tenantId) {
16
+ try {
17
+ const res = await api(creds, token, `/api/admin/studio/tenants/${tenantId}/studio/status`);
18
+ return res.ok;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ export async function login() {
25
+ const { accessToken: access, refreshToken } = await deviceLogin();
26
+ if (!refreshToken) {
27
+ console.error('✗ The sign-in completed but returned no offline token. Is offline_access enabled on the CLI client?');
28
+ process.exit(1);
29
+ }
30
+ const claims = decodeJwtPayload(access);
31
+ const creds = {
32
+ authBase: authBase(),
33
+ realm: authRealm(),
34
+ clientId: oauthClientId(),
35
+ apiBase: apiBase(),
36
+ refreshToken,
37
+ email: claims.email ?? null
38
+ };
39
+
40
+ const res = await api(creds, access, '/api/me/memberships');
41
+ if (!res.ok) {
42
+ console.error(`✗ Signed in, but the workspace list could not be read (${res.status}).`);
43
+ process.exit(1);
44
+ }
45
+ const memberships = await res.json();
46
+ if (memberships.length === 0) {
47
+ console.error('✗ This account has no workspaces. Create a developer account at https://port60.com/developers/ first.');
48
+ process.exit(1);
49
+ }
50
+
51
+ let workspace = null;
52
+ if (memberships.length === 1) {
53
+ workspace = memberships[0];
54
+ } else {
55
+ const probes = await Promise.all(
56
+ memberships.map(async (m) => ((await studioProbe(creds, access, m.tenantId)) ? m : null))
57
+ );
58
+ const studios = probes.filter(Boolean);
59
+ if (studios.length === 1) {
60
+ workspace = studios[0];
61
+ } else {
62
+ const candidates = studios.length > 0 ? studios : memberships;
63
+ console.log('\n Your workspaces:');
64
+ candidates.forEach((m, i) => console.log(` ${i + 1}. ${m.workspaceName}`));
65
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
66
+ const answer = await rl.question(' Which one is your template studio? ');
67
+ rl.close();
68
+ workspace = candidates[Number(answer) - 1];
69
+ if (!workspace) {
70
+ console.error('✗ No such option. Run login again.');
71
+ process.exit(1);
72
+ }
73
+ }
74
+ }
75
+
76
+ saveCredentials({ ...creds, workspace: { tenantId: workspace.tenantId, name: workspace.workspaceName } });
77
+ console.log('');
78
+ console.log(`✓ Signed in${creds.email ? ` as ${creds.email}` : ''}.`);
79
+ console.log(` Studio workspace: ${workspace.workspaceName}`);
80
+ console.log(' Not the account you meant? Run: p60-template-kit logout, then login again.');
81
+ console.log(' Publish from a template directory with: p60-template-kit publish');
82
+ }
83
+
84
+ export async function whoami() {
85
+ const creds = requireCredentials();
86
+ const token = await accessToken(creds);
87
+ const claims = decodeJwtPayload(token);
88
+ console.log(`Signed in as ${claims.email ?? creds.email ?? claims.sub}`);
89
+ console.log(`Studio workspace: ${creds.workspace?.name ?? '(none chosen)'}`);
90
+ console.log(`Platform API: ${creds.apiBase}`);
91
+ }
92
+
93
+ export async function logout() {
94
+ const creds = loadCredentials();
95
+ if (!creds) {
96
+ console.log('Not signed in.');
97
+ return;
98
+ }
99
+ await revoke(creds);
100
+ clearCredentials();
101
+ console.log('✓ Signed out; this machine\'s token has been revoked.');
102
+ }
@@ -0,0 +1,94 @@
1
+ import { resolve } from 'node:path';
2
+ import { validateArtifact } from '../vendor/validator/validate.mjs';
3
+ import { loadArtifactDir } from '../lib/artifactFiles.mjs';
4
+ import { buildZip } from '../lib/zip.mjs';
5
+ import { accessToken, api, requireCredentials } from '../lib/auth.mjs';
6
+
7
+ /**
8
+ * `publish [dir]` (developer CLI stream A): validate, package and upload in one breath, then
9
+ * submit the version for review. The upload lane is manifest-routed, so an unclaimed name CREATES
10
+ * the template and a claimed one gains a version — the server runs the identical validation
11
+ * again, and a human reviews before anything can reach a charity's site. `--no-submit` stops at
12
+ * validated (submit later from the studio).
13
+ */
14
+ export async function publish(args) {
15
+ const creds = requireCredentials();
16
+ if (!creds.workspace?.tenantId) {
17
+ console.error('✗ No studio workspace on record. Run: p60-template-kit login');
18
+ process.exit(1);
19
+ }
20
+ const dir = resolve(args._[0] ?? '.');
21
+ const files = loadArtifactDir(dir);
22
+ const { errors, manifest } = await validateArtifact(files);
23
+ if (errors.length > 0) {
24
+ console.error(`✗ not publishing, ${errors.length} validation error${errors.length === 1 ? '' : 's'}:`);
25
+ for (const e of errors) console.error(` - ${e}`);
26
+ process.exit(1);
27
+ }
28
+ const zip = buildZip(Object.entries(files).map(([path, content]) => ({ path, content })));
29
+ console.log(`✓ ${manifest.name} ${manifest.version} validated, uploading…`);
30
+
31
+ const token = await accessToken(creds);
32
+ const studio = `/api/admin/studio/tenants/${creds.workspace.tenantId}/studio`;
33
+ const res = await api(creds, token, `${studio}/uploads`, {
34
+ method: 'POST',
35
+ headers: { 'content-type': 'application/zip' },
36
+ body: zip
37
+ });
38
+ if (!res.ok) {
39
+ const text = await res.text();
40
+ let message = text;
41
+ try {
42
+ const body = JSON.parse(text);
43
+ message = body.message ?? body.detail ?? text;
44
+ } catch {
45
+ // plain-text error, shown as-is
46
+ }
47
+ console.error(`✗ Upload refused (${res.status}): ${message}`);
48
+ if (res.status === 403) {
49
+ // The CLI holds the developer's OWN token; the server's permission model decides, exactly
50
+ // as in the studio UI. A role below the studio's minimum publishes nowhere.
51
+ console.error(' This account doesn\'t have permission to publish in that workspace.');
52
+ console.error(' Sign in with an account that administers the developer studio.');
53
+ }
54
+ process.exit(1);
55
+ }
56
+ const outcome = await res.json();
57
+ const upload = outcome.upload ?? {};
58
+ if (outcome.templateCreated) {
59
+ console.log(`✓ ${outcome.templateLabel} created from its manifest.`);
60
+ }
61
+ if (!upload.accepted) {
62
+ console.error('✗ The platform rejected the version:');
63
+ for (const e of upload.errors ?? []) console.error(` - ${e}`);
64
+ process.exit(1);
65
+ }
66
+ for (const w of upload.warnings ?? []) console.log(` ⚠ ${w}`);
67
+ console.log(`✓ Version ${upload.version} uploaded and validated.`);
68
+
69
+ if (args['no-submit']) {
70
+ console.log(' Left unsubmitted (--no-submit). Submit it for review from your studio.');
71
+ return;
72
+ }
73
+
74
+ // Submission needs the version row's id; the detail read supplies it.
75
+ const detailRes = await api(creds, token, `${studio}/templates/${outcome.templateId}`);
76
+ if (!detailRes.ok) {
77
+ console.error(`⚠ Uploaded, but the submit step could not read the template (${detailRes.status}). Submit from your studio.`);
78
+ process.exit(1);
79
+ }
80
+ const detail = await detailRes.json();
81
+ const row = (detail.versions ?? []).find((v) => v.version === upload.version && v.status === 'VALIDATED');
82
+ if (!row) {
83
+ console.error('⚠ Uploaded, but the new version was not found in a submittable state. Submit from your studio.');
84
+ process.exit(1);
85
+ }
86
+ const submitRes = await api(creds, token, `${studio}/templates/${outcome.templateId}/versions/${row.id}/submit`, {
87
+ method: 'POST'
88
+ });
89
+ if (!submitRes.ok) {
90
+ console.error(`⚠ Uploaded, but submitting for review failed (${submitRes.status}). Submit from your studio.`);
91
+ process.exit(1);
92
+ }
93
+ console.log('✓ Submitted for review. You\'ll get an email when a reviewer decides.');
94
+ }
@@ -0,0 +1,195 @@
1
+ import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+
6
+ /**
7
+ * The kit's sign-in plumbing (developer CLI stream A, docs/developer-cli-and-ci-publishing.md).
8
+ * The OAuth Device Authorization Grant against the platform's customer realm: the BROWSER does
9
+ * the actual sign-in (so passwordless email codes and Google both work unchanged), the terminal
10
+ * only ever holds the resulting tokens. The refresh token is an offline token, stored 0600 in
11
+ * ~/.port60/credentials.json and revocable from the account's sessions at any time.
12
+ */
13
+
14
+ const STORE_DIR = join(homedir(), '.port60');
15
+ const STORE_FILE = join(STORE_DIR, 'credentials.json');
16
+
17
+ export const authBase = () => (process.env.P60_AUTH_BASE || 'https://auth.port60.com').replace(/\/+$/, '');
18
+ export const authRealm = () => process.env.P60_REALM || 'port60-customers';
19
+ export const apiBase = () => (process.env.P60_API_BASE || 'https://api.port60.com').replace(/\/+$/, '');
20
+ export const oauthClientId = () => process.env.P60_CLIENT_ID || 'port60-kit';
21
+
22
+ const tokenUrl = (base, realm) => `${base}/realms/${realm}/protocol/openid-connect/token`;
23
+
24
+ export function loadCredentials() {
25
+ try {
26
+ return JSON.parse(readFileSync(STORE_FILE, 'utf8'));
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ export function saveCredentials(creds) {
33
+ mkdirSync(STORE_DIR, { recursive: true, mode: 0o700 });
34
+ writeFileSync(STORE_FILE, JSON.stringify(creds, null, 2) + '\n', { mode: 0o600 });
35
+ chmodSync(STORE_FILE, 0o600); // writeFileSync mode is ignored when the file already exists
36
+ }
37
+
38
+ export function clearCredentials() {
39
+ rmSync(STORE_FILE, { force: true });
40
+ }
41
+
42
+ export function requireCredentials() {
43
+ const creds = loadCredentials();
44
+ if (!creds?.refreshToken) {
45
+ console.error('✗ Not signed in. Run: p60-template-kit login');
46
+ process.exit(1);
47
+ }
48
+ return creds;
49
+ }
50
+
51
+ /** The JWT payload, unverified — display only (email, subject). The server verifies for real. */
52
+ export function decodeJwtPayload(token) {
53
+ try {
54
+ return JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'));
55
+ } catch {
56
+ return {};
57
+ }
58
+ }
59
+
60
+ async function form(url, params) {
61
+ const res = await fetch(url, {
62
+ method: 'POST',
63
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
64
+ body: new URLSearchParams(params)
65
+ });
66
+ const text = await res.text();
67
+ let body = {};
68
+ try {
69
+ body = JSON.parse(text);
70
+ } catch {
71
+ // non-JSON error page; the status carries the story
72
+ }
73
+ return { ok: res.ok, status: res.status, body };
74
+ }
75
+
76
+ /** A fresh access token from the stored offline token; rotates the stored refresh token when the
77
+ * server hands a new one back. */
78
+ export async function accessToken(creds) {
79
+ const { ok, status, body } = await form(tokenUrl(creds.authBase, creds.realm), {
80
+ grant_type: 'refresh_token',
81
+ client_id: creds.clientId,
82
+ refresh_token: creds.refreshToken
83
+ });
84
+ if (!ok) {
85
+ console.error(`✗ Your sign-in is no longer valid (${body.error ?? status}). Run: p60-template-kit login`);
86
+ process.exit(1);
87
+ }
88
+ if (body.refresh_token && body.refresh_token !== creds.refreshToken) {
89
+ saveCredentials({ ...creds, refreshToken: body.refresh_token });
90
+ }
91
+ return body.access_token;
92
+ }
93
+
94
+ function tryOpenBrowser(url) {
95
+ const opener = process.platform === 'darwin' ? 'open'
96
+ : process.platform === 'win32' ? 'start' : 'xdg-open';
97
+ try {
98
+ spawn(opener, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
99
+ } catch {
100
+ // no browser to open; the printed URL is the path
101
+ }
102
+ }
103
+
104
+ const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
105
+
106
+ /**
107
+ * The device flow itself: ask for a code, hand the human to the browser, poll until the sign-in
108
+ * completes. Returns {accessToken, refreshToken}.
109
+ */
110
+ export async function deviceLogin() {
111
+ const base = authBase();
112
+ const realm = authRealm();
113
+ const clientId = oauthClientId();
114
+ // prompt=login forces a fresh, intentional sign-in at the verification page even when the
115
+ // browser already holds an SSO session — so a developer who is also signed in as (say) a charity
116
+ // editor can never silently authorise the CLI as the wrong account. Login is rare (the offline
117
+ // token lasts), so paying for intentionality every time is the right trade.
118
+ const start = await form(`${base}/realms/${realm}/protocol/openid-connect/auth/device`, {
119
+ client_id: clientId,
120
+ scope: 'openid offline_access',
121
+ prompt: 'login'
122
+ });
123
+ if (!start.ok) {
124
+ console.error(`✗ Could not start the sign-in (${start.body.error_description ?? start.body.error ?? start.status}).`);
125
+ if (start.status === 404 || start.body.error === 'invalid_client') {
126
+ console.error(' The sign-in service may not have the CLI client enabled yet.');
127
+ }
128
+ process.exit(1);
129
+ }
130
+ const { device_code: deviceCode, user_code: userCode, verification_uri: uri,
131
+ verification_uri_complete: uriComplete, interval = 5, expires_in: expiresIn = 600 } = start.body;
132
+
133
+ console.log('');
134
+ console.log(` Visit: ${uri}`);
135
+ console.log(` Code: ${userCode}`);
136
+ console.log('');
137
+ console.log(' Sign in there with the account you want to PUBLISH AS — you will be asked to sign in');
138
+ console.log(' even if your browser is already logged in, so pick the right one. This terminal will notice.');
139
+ if (uriComplete) tryOpenBrowser(uriComplete);
140
+
141
+ const deadline = Date.now() + expiresIn * 1000;
142
+ let waitSeconds = interval;
143
+ while (Date.now() < deadline) {
144
+ await sleep(waitSeconds);
145
+ const poll = await form(tokenUrl(base, realm), {
146
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
147
+ client_id: clientId,
148
+ device_code: deviceCode
149
+ });
150
+ if (poll.ok) {
151
+ return { accessToken: poll.body.access_token, refreshToken: poll.body.refresh_token };
152
+ }
153
+ switch (poll.body.error) {
154
+ case 'authorization_pending':
155
+ break;
156
+ case 'slow_down':
157
+ waitSeconds += 5;
158
+ break;
159
+ case 'access_denied':
160
+ console.error('✗ The sign-in was declined in the browser.');
161
+ process.exit(1);
162
+ break;
163
+ case 'expired_token':
164
+ console.error('✗ The code expired before the sign-in completed. Run login again.');
165
+ process.exit(1);
166
+ break;
167
+ default:
168
+ console.error(`✗ Sign-in failed (${poll.body.error_description ?? poll.body.error ?? poll.status}).`);
169
+ process.exit(1);
170
+ }
171
+ }
172
+ console.error('✗ The code expired before the sign-in completed. Run login again.');
173
+ process.exit(1);
174
+ }
175
+
176
+ /** Best-effort server-side revocation of the offline token; local removal always follows. */
177
+ export async function revoke(creds) {
178
+ try {
179
+ await form(`${creds.authBase}/realms/${creds.realm}/protocol/openid-connect/logout`, {
180
+ client_id: creds.clientId,
181
+ refresh_token: creds.refreshToken
182
+ });
183
+ } catch {
184
+ // unreachable server; the local removal still signs this machine out
185
+ }
186
+ }
187
+
188
+ /** An authenticated platform-API call. */
189
+ export async function api(creds, token, path, init = {}) {
190
+ const res = await fetch(`${creds.apiBase}${path}`, {
191
+ ...init,
192
+ headers: { authorization: `Bearer ${token}`, ...(init.headers ?? {}) }
193
+ });
194
+ return res;
195
+ }
@@ -892,6 +892,12 @@
892
892
  "kind": "text",
893
893
  "required": false,
894
894
  "description": "Optional introduction."
895
+ },
896
+ {
897
+ "name": "mapSlug",
898
+ "kind": "text",
899
+ "required": false,
900
+ "description": "WHICH map renders here, by its address (the Pages editor's placement picker writes it; the Maps area is where maps exist). Absent = the tenant's first published map."
895
901
  }
896
902
  ],
897
903
  "minimal": {},
@@ -12,6 +12,9 @@
12
12
  { "name": "--accent", "description": "Action accent (the donate CTA family)." },
13
13
  { "name": "--accent-strong", "description": "Darker accent." },
14
14
  { "name": "--gold", "description": "Highlight tint (marks, ratings)." },
15
+ { "name": "--marker", "description": "Impact-map pin colour (the live-map markers are a zoom-scaling circle layer that take this colour). Defaults to --accent." },
16
+ { "name": "--map-mode", "description": "Impact-map basemap colour mode read by the map island: auto (derive the map's paper/ink from the theme), light, dark, or muted. Default auto." },
17
+ { "name": "--map-projection", "description": "Impact-map projection: flat (default) or globe — the planet as a rotatable sphere with atmosphere." },
15
18
  { "name": "--line", "description": "Hairline borders." },
16
19
  { "name": "--line-strong", "description": "Emphasised borders (selected, focus)." },
17
20
  { "name": "--danger", "description": "Errors and destructive affordances." },
@@ -20,6 +20,15 @@
20
20
  --accent: #ef6044;
21
21
  --accent-strong: #d94d33;
22
22
  --gold: #f3b13b;
23
+ /* Impact map. --marker = the map pin colour (a deliberate brand accent by default; the live-map
24
+ markers are a zoom-scaling MapLibre circle layer that takes this colour). --map-mode = the
25
+ basemap colour mode read by the map island: `auto` derives the map's paper/ink from the theme
26
+ neutrals below, `light`/`dark`/`muted` force a base. Template-owned — set in theme.css or exposed
27
+ as Appearance knobs; the tenant never edits map colour directly. */
28
+ --marker: var(--accent);
29
+ --map-mode: auto;
30
+ /* flat (default) or globe — MapLibre's own globe projection, a rotatable sphere. */
31
+ --map-projection: flat;
23
32
  --line: rgba(31, 42, 51, 0.1);
24
33
  --line-strong: rgba(18, 135, 122, 0.28);
25
34
  --danger: #c0392b;
@@ -953,6 +962,22 @@ textarea.p60-form-control {
953
962
  gap: 1rem;
954
963
  }
955
964
 
965
+ /* MOBILE BREAKOUT (owner, 2026-08-17): on phones the nesting stack squashes the card badly —
966
+ measured on Sanctuary: 390px viewport → 346 container → 294 aside → 249px card → ~195px of
967
+ actual controls. A donate form is a working control surface; it gets the full screen width
968
+ (edge to edge, side borders/radius dropped, its own padding kept). The negative-margin trick
969
+ escapes ANY wrapper depth; layouts here are symmetric, so the sheet lands exactly 0..100vw. */
970
+ @media (max-width: 640px) {
971
+ .donate-card {
972
+ width: 100vw;
973
+ margin-left: calc(50% - 50vw);
974
+ border-radius: 0;
975
+ border-left: 0;
976
+ border-right: 0;
977
+ padding: 1.4rem 1.1rem;
978
+ }
979
+ }
980
+
956
981
  /* Optional cause image banner at the top of the widget (set per-cause in tenant-admin). */
957
982
  .donate-cause-img {
958
983
  width: 100%;
@@ -1966,6 +1991,30 @@ textarea.p60-form-control {
1966
1991
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
1967
1992
  gap: 1.5rem;
1968
1993
  }
1994
+ /* MOBILE: the stacked grid becomes an edge-to-edge SWIPE ROW with a peek (owner, 2026-08-17) —
1995
+ the scroller bleeds to the screen edges, each card keeps its padding, the first card aligns to
1996
+ the text gutter, and the cut-off next card IS the "swipe me" affordance (the reference maps'
1997
+ card idiom). Snap keeps cards settling cleanly; the scrollbar is hidden, the peek replaces it. */
1998
+ @media (max-width: 640px) {
1999
+ .article-grid {
2000
+ display: flex;
2001
+ width: 100vw;
2002
+ margin-left: calc(50% - 50vw);
2003
+ overflow-x: auto;
2004
+ -webkit-overflow-scrolling: touch;
2005
+ scroll-snap-type: x proximity;
2006
+ gap: 0.9rem;
2007
+ padding: 0.2rem 22px 0.6rem;
2008
+ scroll-padding-left: 22px;
2009
+ scrollbar-width: none;
2010
+ }
2011
+ .article-grid::-webkit-scrollbar { display: none; }
2012
+ .article-grid > .article-card {
2013
+ flex: 0 0 78%;
2014
+ max-width: 330px;
2015
+ scroll-snap-align: start;
2016
+ }
2017
+ }
1969
2018
  .article-card {
1970
2019
  display: flex;
1971
2020
  flex-direction: column;
@@ -2355,6 +2404,26 @@ textarea.p60-form-control {
2355
2404
  align-items: center;
2356
2405
  gap: 0.5rem;
2357
2406
  }
2407
+ /* MOBILE: the rotator is MEDIA — it bleeds edge to edge like the hero and the map, with the
2408
+ arrows overlaid on the image instead of eating width beside it. */
2409
+ @media (max-width: 640px) {
2410
+ .events-carousel {
2411
+ position: relative;
2412
+ width: 100vw;
2413
+ margin-left: calc(50% - 50vw);
2414
+ gap: 0;
2415
+ }
2416
+ .events-carousel .carousel-slide { border-radius: 0; }
2417
+ .events-carousel > .carousel-arrow {
2418
+ position: absolute;
2419
+ top: 50%;
2420
+ transform: translateY(-50%);
2421
+ z-index: 2;
2422
+ opacity: 0.9;
2423
+ }
2424
+ .events-carousel > .carousel-arrow:first-of-type { left: 10px; }
2425
+ .events-carousel > .carousel-arrow:last-of-type { right: 10px; }
2426
+ }
2358
2427
  .carousel-slide {
2359
2428
  position: relative;
2360
2429
  flex: 1;
@@ -4228,23 +4297,98 @@ textarea.p60-form-control {
4228
4297
  .people-grid { grid-template-columns: 1fr; }
4229
4298
  }
4230
4299
 
4231
- /* ── Impact map island (WP4) — baseline look; templates restyle via the styling API. Colours ride
4232
- currentColor and the theme's tokens so the island wears the template's clothes. */
4300
+ /* ── Impact map island (WP4) — the OVERLAY borrows the theme: pins wear --marker (a brand accent by
4301
+ default), the card is a themed --surface. The BASEMAP colour is derived by the map island from the
4302
+ theme + --map-mode (see lib/mapTheme.ts). Brand on the overlay, neutrals on the map — the recipe
4303
+ that stays legible on any theme. Templates restyle any of this via the tokens or these classes. */
4233
4304
  .impact-map { margin: 24px 0; }
4234
4305
  .impact-map-head h2 { margin: 0 0 6px; }
4235
4306
  .impact-map-intro { margin: 0 0 14px; opacity: 0.85; }
4236
- .impact-map-canvas { position: relative; min-height: 340px; border-radius: 14px; overflow: hidden;
4237
- background: linear-gradient(160deg, rgba(0, 0, 0, 0.28), rgba(0, 0, 0, 0.55)), currentColor; }
4307
+ .impact-map-stats { margin: 0 0 14px; color: var(--muted); font-weight: 600; letter-spacing: 0.01em; }
4308
+ /* The stable stage, shaped like THE WORLD BAND ITSELF: the island's planet view is all 360° of
4309
+ longitude by 84°N–58°S (whole Greenland in, only the polar void and Antarctica trimmed), which in
4310
+ Web Mercator is a 1.497:1 box — so a frame with that exact ratio holds the full planet edge to
4311
+ edge, full width AND full height, at any screen size. A ratio wider than the band (a max-height
4312
+ clamp once made it so on large screens) turns the fit height-constrained and the world renders
4313
+ NARROWER than the frame — dead bars at the sides; a narrower ratio (min-height on phones) just
4314
+ adds benign vertical margin. Relative + clipped so MapLibre always has a real box and can never
4315
+ escape it. Keep this ratio in step with the island's planet bounds. */
4316
+ .impact-map-stage { position: relative; width: 100%; aspect-ratio: 1.497; min-height: 300px;
4317
+ border-radius: 14px; overflow: hidden; }
4318
+ /* width:100% is LOAD-BEARING: with height pinned by min-height and width left auto, CSS derives
4319
+ width FROM the aspect-ratio (300 x 1.497 = 449px) — on phones the stage overflowed the page
4320
+ sideways, shoving every other section into a lopsided "left with a right gutter" layout. Width
4321
+ pinned, the ratio yields height and min-height only ever makes the stage TALLER. */
4322
+ /* MOBILE BREAKOUT: a map is a canvas — every pixel is information, nothing needs a margin — so on
4323
+ phones it bleeds edge to edge (media bleeds, prose gutters: the hero already sets the rhythm).
4324
+ Cooperative gestures keep page-scrolling safe over the full-width canvas. */
4325
+ @media (max-width: 640px) {
4326
+ .impact-map-stage {
4327
+ width: 100vw;
4328
+ margin-left: calc(50% - 50vw);
4329
+ border-radius: 0;
4330
+ }
4331
+ }
4332
+ .impact-map-canvas { position: absolute; inset: 0;
4333
+ background: linear-gradient(160deg, rgba(0, 0, 0, 0.28), rgba(0, 0, 0, 0.55)), var(--muted); }
4238
4334
  .impact-map-pin { position: absolute; width: 16px; height: 16px; border-radius: 50%;
4239
- transform: translate(-50%, -50%); border: 2px solid #fff; background: currentColor;
4335
+ transform: translate(-50%, -50%); border: 2px solid var(--surface); background: var(--marker);
4240
4336
  cursor: pointer; padding: 0; transition: transform 120ms ease; }
4241
4337
  .impact-map-pin:hover, .impact-map-pin:focus-visible { transform: translate(-50%, -50%) scale(1.35); }
4242
- .impact-map-pin--active { box-shadow: 0 0 0 5px rgb(255 255 255 / 28%); }
4243
- .impact-map-card { margin-top: 14px; border: 1px solid rgb(127 127 127 / 30%); border-radius: 12px;
4338
+ .impact-map-pin--active { box-shadow: 0 0 0 5px var(--primary-soft); }
4339
+ .impact-map-card { margin-top: 14px; background: var(--surface); color: var(--ink);
4340
+ border: 1px solid var(--line); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm);
4244
4341
  padding: 16px 18px; max-width: 34rem; }
4342
+ /* On the LIVE map the below-map card is a SMALL-SCREEN device: the anchored popup is taller than a
4343
+ phone-height stage, so the marker selection drives this card instead (Google-Maps-mobile idiom).
4344
+ Desktop keeps the inline popup and hides this. The pin-canvas fallback (no modifier) shows the
4345
+ card on every screen size, as it always has. */
4346
+ .impact-map-card--small-screens { display: none; }
4347
+ @media (max-width: 640px) {
4348
+ /* Full width, flush under the full-bleed map — the two read as one attached sheet (the same
4349
+ breakout as the donate card; the hairline top border is the seam between map and card). */
4350
+ .impact-map-card--small-screens {
4351
+ display: block;
4352
+ width: 100vw;
4353
+ margin: 0 0 0 calc(50% - 50vw);
4354
+ max-width: none;
4355
+ border-radius: 0;
4356
+ border-left: 0;
4357
+ border-right: 0;
4358
+ }
4359
+ }
4245
4360
  .impact-map-card-kind { display: inline-block; font-size: 11px; font-weight: 700;
4246
- letter-spacing: 0.06em; text-transform: uppercase; opacity: 0.65; margin-bottom: 4px; }
4361
+ letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); margin-bottom: 4px; }
4247
4362
  .impact-map-card-title { margin: 0 0 6px; }
4248
4363
  .impact-map-card-summary { margin: 0 0 10px; opacity: 0.9; }
4249
- .impact-map-card-cta { font-weight: 600; }
4364
+ .impact-map-card-cta { font-weight: 600; color: var(--primary); }
4250
4365
  .impact-map-empty { opacity: 0.7; }
4366
+ /* The live tile layer (slice 2): fills the stage from first render so MapLibre measures a real box;
4367
+ the pin canvas overlays it until (unless) the map is ready, then unmounts to reveal the map.
4368
+ Sized with width/height (NOT inset) because MapLibre forces position:relative on this element,
4369
+ which would defeat absolute insets and collapse the box to zero height. */
4370
+ .impact-map-gl { width: 100%; height: 100%; }
4371
+ /* Live-map markers are a MapLibre circle layer (WebGL) — they scale with zoom and take their colour
4372
+ from --marker (circle-color) — so there is no DOM marker element to style here. The pin canvas
4373
+ fallback above keeps its own .impact-map-pin dots for the no-tiles case. */
4374
+ /* The INLINE marker card (a MapLibre popup anchored at the clicked marker — where "See more"
4375
+ lives): the shell takes the theme's surface; the tip is recoloured per anchor side to match. */
4376
+ .impact-map-popup .maplibregl-popup-content { background: var(--surface); color: var(--ink);
4377
+ border: 1px solid var(--line); border-radius: var(--radius-sm); box-shadow: var(--shadow);
4378
+ padding: 14px 16px; }
4379
+ .impact-map-popup.maplibregl-popup-anchor-bottom .maplibregl-popup-tip,
4380
+ .impact-map-popup.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip,
4381
+ .impact-map-popup.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip { border-top-color: var(--surface); }
4382
+ .impact-map-popup.maplibregl-popup-anchor-top .maplibregl-popup-tip,
4383
+ .impact-map-popup.maplibregl-popup-anchor-top-left .maplibregl-popup-tip,
4384
+ .impact-map-popup.maplibregl-popup-anchor-top-right .maplibregl-popup-tip { border-bottom-color: var(--surface); }
4385
+ .impact-map-popup.maplibregl-popup-anchor-left .maplibregl-popup-tip { border-right-color: var(--surface); }
4386
+ .impact-map-popup.maplibregl-popup-anchor-right .maplibregl-popup-tip { border-left-color: var(--surface); }
4387
+ .impact-map-popup-card .impact-map-card-title { margin: 4px 0 6px; font-size: 1.02rem; }
4388
+ .impact-map-popup-card .impact-map-card-summary { margin: 0 0 8px; }
4389
+ /* The point's image (when it has one): bleeds to the popup's top edge; plain rounded in the
4390
+ fallback card. Absent image = the card simply starts at the kind line. */
4391
+ .impact-map-card-image { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover;
4392
+ border-radius: 10px; margin: 0 0 10px; }
4393
+ .impact-map-popup-card .impact-map-card-image { width: calc(100% + 32px); margin: -14px -16px 10px;
4394
+ border-radius: calc(var(--radius-sm) - 1px) calc(var(--radius-sm) - 1px) 0 0; }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "starter",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "format": "port60-liquid@1",
5
5
  "label": "Starter",
6
6
  "description": "The reference template for the Port60 dialect, the developer docs' worked example and the base to copy when building your own. 1.1.0 adds the layout slot: the template owns the header, navigation and footer chrome.",
@@ -15,12 +15,14 @@
15
15
  "values",
16
16
  "cta",
17
17
  "people",
18
- "campaigns"
18
+ "campaigns",
19
+ "impactMap"
19
20
  ],
20
21
  "islands": [
21
22
  "donation_widget",
22
23
  "member_menu",
23
- "hero_carousel"
24
+ "hero_carousel",
25
+ "map"
24
26
  ],
25
27
  "layout": true,
26
28
  "heroImagery": true,
@@ -46,7 +48,7 @@
46
48
  }
47
49
  ]
48
50
  },
49
- "changelog": "Demonstrates the behaviour catalogue: the values rail reveals with a stagger and a multi-photo standard hero becomes an engine-wired carousel.",
51
+ "changelog": "Adds the impact map section: pin your located work on a themed map of where you work.",
50
52
  "imagery": {
51
53
  "hero": {
52
54
  "idealAspect": "16:9",
@@ -0,0 +1,5 @@
1
+ <section class="section">
2
+ <div class="container">
3
+ {% island 'map' %}
4
+ </div>
5
+ </section>