Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .impeccable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Design Context: jscebu.org

Distilled from the approved specs:
- `docs/superpowers/specs/2026-10-27-jscebu-design-system-design.md`
- `docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md`

The brand rules themselves live in `packages/brand` and at https://www.jscebu.org/brand.md. This file covers who the site is for and how it should feel.

## Audience

- **Developers in Cebu:** students, juniors and working devs who come to meetups, workshops and Hacktoberfest Cebu.
- **Volunteers, co-leads and partner communities:** they make things for JS Cebu.
- **AI agents:** they make on-brand graphics and pages, given only the brand link.

## Use cases

- Find the next event. For now that means Cebby and Facebook.
- See that the community is real and active: real photos, real projects.
- Find where to join.
- Get the logos and brand rules.

## Personality

- **The website uses the Terminal personality:** ink background, a faint grid, yellow light.
- **The two signatures carry recognition:** the block cursor at the end of the primary headline, and the logo as a tilted die-cut sticker.
- **Voice:**
- English first, friendly and direct, like a fellow developer.
- Bisaya only lightly, and only approved phrases.
- **Photos:** real and never filtered.
- **No hexagon motifs** beyond the logo itself.

## Motion

Added 2026-10-28, because Dorell said "a little bit of animations. right now, it's zero".

- **A little, and purposeful.** Motion follows the brand's two signatures: text arriving like terminal output, and the sticker being slapped on.
- **Easing and timing:** decelerating curves (ease-out-expo or quart), never bounce. Entrances run 400–700ms; feedback runs 100–250ms. Animate only transform and opacity.
- **One hero moment per page,** plus quiet reveals as sections scroll in and small hover and press feedback on things you can click.
- **Under `prefers-reduced-motion: reduce`, nothing moves,** and the cursor doesn't blink. Content never depends on JavaScript to become visible.
40 changes: 36 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,46 @@

Thanks for helping JavaScript Cebu.

## How changes ship

Every change, from a person or an AI agent, follows the same path:

1. **Intent.** Open an issue, or start a conversation, that says what should change and why.
2. **Plan.** Larger changes get a written spec and plan under `docs/superpowers/` before anyone writes code.
3. **Approval.** A JS Cebu lead approves the plan.
4. **Implementation.** Build it on a branch and open a pull request.
5. **Review.**
- CI runs `pnpm install --frozen-lockfile && pnpm test && pnpm build` on every pull request.
- A reviewer reads the whole branch.
6. **Merge.** Merging to `main` deploys www.jscebu.org.

## Deploys

**Merging to `main` is the deploy.** Cloudflare Workers Builds builds `main` and deploys the `jscebu-org` Worker with these settings:

| Setting | Value |
|---|---|
| Root directory | repo root |
| Build command | `pnpm install --frozen-lockfile && pnpm test && pnpm --filter website build` |
| Deploy command | `pnpm --filter website exec wrangler deploy` |
| Other branches | `pnpm --filter website exec wrangler versions upload` (a preview URL per branch) |

Node comes from `.nvmrc` and pnpm from `packageManager` in `package.json`. A failing test or post-build check stops the deploy.

Don't run `wrangler deploy` by hand.

Be careful with `routes` in `apps/website/wrangler.jsonc`. www.jscebu.org is attached there as a Custom Domain. Deploys from CI replace any existing DNS record for a Custom Domain they list, so add or change a domain only through a reviewed pull request.

## Brand changes are releases

`packages/brand` is the JS Cebu design system. A change to a color, font, rule, logo or template is a **versioned release** (for example v0.1 → v0.2), not a drive-by edit:

1. Open an issue or PR that says what changes and why.
2. Bump `version` in `packages/brand/src/brand.ts` and add a `changelog` entry describing the change.
1. Bump `version` in `packages/brand/src/brand.ts`.
2. Add a `changelog` entry describing the change.
3. A JS Cebu lead approves it before merge.

Bisaya copy appears only after a lead approves the phrase, which means its `status` is `approved`.

## Before you open a PR

```sh
Expand All @@ -18,8 +50,8 @@ pnpm test
pnpm build
```

All tests must pass. CI runs the same commands.
All tests and checks must pass. CI runs the same commands.

## Never commit secrets

This repository is public. No tokens, API keys, form responses or personal data. Deploy credentials live in Cloudflare and GitHub secrets.
This repository is public. Never commit tokens, API keys, form responses or personal data. Deploy credentials live in Cloudflare and GitHub.
26 changes: 19 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,34 @@
# jscebu.org

Monorepo for [JavaScript Cebu](https://www.jscebu.org).
The website and design system of [JavaScript Cebu](https://www.jscebu.org), live at <https://www.jscebu.org>.

| Path | What it is |
|---|---|
| `packages/brand` | The JS Cebu design system: brand data, generators for `tokens.css` and `brand.md`, logos and templates |
| `apps/website` | The Astro site deployed to Cloudflare, starting with [jscebu.org/brand](https://www.jscebu.org/brand) |
| `packages/brand` | The JS Cebu design system: brand data, the generators for `tokens.css`, `brand.md` and `llms.txt`, logos, templates and an HTML checker for the brand's MUST rules |
| `apps/website` | The Astro site: `/`, `/projects`, `/about`, `/links` and `/brand`. It deploys as the Cloudflare Worker `jscebu-org`, which serves www.jscebu.org |

- Brand guidelines: <https://www.jscebu.org/brand>
- Brand guidelines: <https://www.jscebu.org/brand/>
- For AI agents: <https://www.jscebu.org/brand.md>
- Design spec: [docs/superpowers/specs/2026-10-27-jscebu-design-system-design.md](docs/superpowers/specs/2026-10-27-jscebu-design-system-design.md)
- Design specs: [design system](docs/superpowers/specs/2026-10-27-jscebu-design-system-design.md) · [site v1](docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md)

## How changes ship

1. Intent
2. Plan
3. Approval
4. Implementation
5. Review
6. Merge

**Merging to `main` deploys to production automatically** through Cloudflare Workers Builds. See [CONTRIBUTING.md](CONTRIBUTING.md).

## Develop

```sh
pnpm install
pnpm test
pnpm build
pnpm build # builds the site and runs the post-build checks
pnpm --filter website dev # local preview
```

Code is MIT-licensed ([LICENSE](LICENSE)). The JS Cebu name and logos are not; see [packages/brand/LICENSE](packages/brand/LICENSE). See [CONTRIBUTING.md](CONTRIBUTING.md) before changing the brand.
Code is MIT-licensed ([LICENSE](LICENSE)). The JS Cebu name and logos are not; see [packages/brand/LICENSE](packages/brand/LICENSE).
3 changes: 2 additions & 1 deletion apps/website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"linkedom": "~0.18.14",
"tsx": "^4.23.15",
"vitest": "^5.0.5",
"wrangler": "^4.153.0"
"wrangler": "^4.153.0",
"yaml": "^2.9.1"
},
"dependencies": {
"@fontsource-variable/archivo": "^5.3.0",
Expand Down
13 changes: 13 additions & 0 deletions apps/website/public/_redirects
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Old Notion-era jscebu.org URLs (spec: docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md §5).
# Targets end in / because folders are served at /name/; a bare /name would redirect twice.
/mission-vision /about/ 301
/members /about/#organizers 301
/iniatives /about/ 301
/initiatives /about/ 301
/official-links /links/ 301
/community-projects /projects/ 301
# Temporary: /events becomes a real page once events come from Cebby.
/events /#events 302
/promotional-materials /brand/ 301
/annual-hacktoberfest https://hacktoberfest.jscebu.org/ 301
/volunteers-training-workshop /about/ 301
54 changes: 41 additions & 13 deletions apps/website/scripts/check-dist.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* Post-build checks over dist/ and src/. Run after `astro build`; exits 1 with a list on any failure.
* - The cursor never wraps alone (and HTML compression didn't eat the space before its word).
* - Every /brand link on the page and in brand.md resolves to a built file.
* - Every link to this site, on every page and in brand.md and llms.txt, resolves to a built file.
* - Every redirect in _redirects that points at this site lands on a built file.
* - Every rule appears on both the page and brand.md ("one source, two readers").
* - No color or font-family literals in src/: everything comes from --jc-* variables.
*/
Expand All @@ -12,8 +13,6 @@ import { brand, lintHtml, type Brand } from '@jscebu/brand';
import { parseHTML } from 'linkedom';

const SITE = 'https://www.jscebu.org';
/** Paths this app serves; everything else still belongs to the legacy site. */
const OURS = /^\/(?:brand(?:[/.]|$)|llms\.txt$|_astro\/)/;

export function findCursorViolations(html: string): string[] {
const out = lintHtml(html)
Expand All @@ -38,8 +37,7 @@ export function findMissingLinks(text: string, fileExists: (path: string) => boo
const paths = urls
.map((u) => (u.startsWith(SITE) ? u.slice(SITE.length) || '/' : u))
.filter((u) => u.startsWith('/') && !u.startsWith('//'))
.map((u) => u.replace(/[?#].*$/, ''))
.filter((p) => OURS.test(p));
.map((u) => u.replace(/[?#].*$/, ''));
return [...new Set(paths)].filter((p) => !fileExists(p));
}

Expand Down Expand Up @@ -81,6 +79,18 @@ export function parityRules(b: Brand): string[] {
];
}

/** Parse a Cloudflare `_redirects` file: `source destination [status]`, status defaulting to 302. */
export function parseRedirects(text: string): { from: string; to: string; status: number }[] {
return text
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'))
.map((line) => {
const [from, to, status] = line.split(/\s+/);
return { from, to, status: status ? Number(status) : 302 };
});
}

function walk(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const p = join(dir, name);
Expand All @@ -92,31 +102,49 @@ function main(): void {
const app = fileURLToPath(new URL('..', import.meta.url));
const dist = join(app, 'dist');
const read = (p: string) => readFileSync(join(dist, p), 'utf8');
/** A URL path is built when it names a file, or ends in / over a folder with index.html. A bare folder path would cost a redirect. */
const exists = (urlPath: string) => {
const p = join(dist, decodeURIComponent(urlPath));
return urlPath.endsWith('/') ? existsSync(join(p, 'index.html')) : existsSync(p) || existsSync(join(p, 'index.html'));
if (urlPath.endsWith('/')) return existsSync(join(p, 'index.html'));
return existsSync(p) && statSync(p).isFile();
};

const required = ['brand/index.html', 'brand.md', 'brand/tokens.css', 'llms.txt', '404.html', 'brand/logo/sticker.svg', 'brand/templates/event-post.html'];
const html = read('brand/index.html');
const required = [
'index.html', 'projects/index.html', 'about/index.html', 'links/index.html', '404.html',
'brand/index.html', 'brand.md', 'brand/tokens.css', 'llms.txt', 'sitemap.xml', 'robots.txt', '_redirects',
'brand/logo/sticker.svg', 'brand/templates/event-post.html',
];
const pages = walk(dist).filter((p) => p.endsWith('.html') && !p.startsWith(join(dist, 'brand', 'templates'))).map((p) => relative(dist, p));
const brandHtml = read('brand/index.html');
const md = read('brand.md');
const { document } = parseHTML(html);
const redirects = existsSync(join(dist, '_redirects')) ? parseRedirects(read('_redirects')) : [];
const src = walk(join(app, 'src')).map((p) => ({ path: relative(app, p), text: readFileSync(p, 'utf8') }));

const errors = [
...required.filter((f) => !existsSync(join(dist, f))).map((f) => `dist/${f} was not built`),
...findCursorViolations(html).map((m) => `/brand cursor: ${m}`),
...findMissingLinks(html, exists).map((p) => `/brand links to ${p}, which was not built`),
...pages.flatMap((page) => {
const html = read(page);
return [
...findCursorViolations(html).map((m) => `${page} cursor: ${m}`),
...findMissingLinks(html, exists).map((p) => `${page} links to ${p}, which was not built`),
];
}),
...findMissingLinks(md, exists).map((p) => `brand.md links to ${p}, which was not built`),
...findParityGaps(document.body.textContent ?? '', md, parityRules(brand)),
...findMissingLinks(read('llms.txt'), exists).map((p) => `llms.txt links to ${p}, which was not built`),
...redirects
.filter((r) => r.to.startsWith('/') && !exists(r.to.replace(/#.*$/, '')))
.map((r) => `_redirects sends ${r.from} to ${r.to}, which was not built`),
...findParityGaps(parseHTML(brandHtml).document.body.textContent ?? '', md, parityRules(brand)),
...findLiterals(src),
];

if (errors.length) {
console.error(`check-dist: ${errors.length} problem(s)\n${errors.map((e) => ` - ${e}`).join('\n')}`);
process.exit(1);
}
console.log(`check-dist: ok (${required.length} files, cursor, links, parity, ${src.length} source files free of literals)`);
console.log(
`check-dist: ok (${required.length} files, ${pages.length} pages: cursor and links; ${redirects.length} redirects; /brand parity; ${src.length} source files free of literals)`,
);
}

if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main();
60 changes: 60 additions & 0 deletions apps/website/scripts/url-matrix.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Check a deployed (or `wrangler dev`) jscebu.org against the v1 spec: old Notion-era URLs
# redirect per §5, pages return 200, brand.md keeps its agent-facing headers, misses 404.
# Usage: scripts/url-matrix.sh https://www.jscebu.org
set -uo pipefail

base=${1:?usage: url-matrix.sh BASE_URL}
base=${base%/}
host=$(printf '%s' "$base" | sed -E 's#^https?://([^/]+).*#\1#')
fail=0

# fetch PATH → sets status, location (host-relative when on the same host) and headers
fetch() {
headers=$(curl -s -o /dev/null -D - --max-time 30 "$base$1" | tr -d '\r')
status=$(printf '%s\n' "$headers" | head -1 | awk '{print $2}')
location=$(printf '%s\n' "$headers" | grep -i '^location:' | head -1 | sed -E 's/^[Ll]ocation: *//; s#^https?://'"$host"'##')
}

report() { # ok? label detail
if [ "$1" = ok ]; then printf 'ok %s\n' "$2"; else printf 'FAIL %s (%s)\n' "$2" "$3"; fail=1; fi
}

redirect() { # path target status
fetch "$1"
if [ "$status" = "$3" ] && [ "$location" = "$2" ]; then report ok "$1 → $3 $2"
else report fail "$1 → $3 $2" "got $status ${location:-no location}"; fi
}

page() { # path [content-type prefix]
fetch "$1"
local type
type=$(printf '%s\n' "$headers" | grep -i '^content-type:' | head -1 | sed -E 's/^[Cc]ontent-[Tt]ype: *//')
if [ "$status" = 200 ] && [[ "$type" == ${2:-}* ]]; then report ok "$1 200 ${type}"
else report fail "$1 200 ${2:-}" "got $status $type"; fi
}

redirect /mission-vision /about/ 301
redirect /members /about/#organizers 301
redirect /iniatives /about/ 301
redirect /initiatives /about/ 301
redirect /official-links /links/ 301
redirect /community-projects /projects/ 301
redirect /events /#events 302
redirect /promotional-materials /brand/ 301
redirect /annual-hacktoberfest https://hacktoberfest.jscebu.org/ 301
redirect /volunteers-training-workshop /about/ 301

for p in / /projects/ /about/ /links/ /brand/; do page "$p" text/html; done
page /brand.md 'text/markdown; charset=utf-8'
if printf '%s\n' "$headers" | grep -qi '^access-control-allow-origin: \*'; then report ok '/brand.md CORS *'; else report fail '/brand.md CORS *' 'header missing'; fi
page /llms.txt 'text/plain; charset=utf-8'
page /brand/tokens.css text/css
page /brand/logo/sticker.svg image/svg+xml
page /sitemap.xml application/xml
page /robots.txt text/plain

fetch /this-page-does-not-exist
if [ "$status" = 404 ]; then report ok '/this-page-does-not-exist 404'; else report fail '/this-page-does-not-exist 404' "got $status"; fi

exit $fail
Binary file added apps/website/src/assets/projects/cebby.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 22 additions & 6 deletions apps/website/src/components/Cursor.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,33 @@
/**
* A headline's text with the brand cursor after the last word. The last word and
* the cursor share a .jc-nw wrapper so the cursor can never wrap onto its own line.
* With `animate`, each word rises in turn (styles in Site.astro); the cursor is there from the start.
*/
interface Props {
text: string;
blink?: boolean;
animate?: boolean;
}

const { text, blink = false } = Astro.props;
const trimmed = text.trim();
const split = trimmed.lastIndexOf(' ');
const rest = split === -1 ? '' : trimmed.slice(0, split + 1);
const last = trimmed.slice(split + 1);
const { text, blink = false, animate = false } = Astro.props;
const words = text.trim().split(/\s+/);
const last = words.pop()!;
const cursorClass = ['jc-cursor', { 'jc-cursor--blink': blink }];
---

{rest}<span class="jc-nw">{last}<span class:list={['jc-cursor', { 'jc-cursor--blink': blink }]} aria-hidden="true"></span></span>
{
animate ? (
<>
{words.map((word, i) => (
<>
<span class="rise" style={`--i: ${i}`}>{word}</span>{' '}
</>
))}
<span class="jc-nw"><span class="rise" style={`--i: ${words.length}`}>{last}</span><span class:list={cursorClass} aria-hidden="true"></span></span>
</>
) : (
<>
{words.length ? `${words.join(' ')} ` : ''}<span class="jc-nw">{last}<span class:list={cursorClass} aria-hidden="true"></span></span>
</>
)
}
Loading
Loading