Skip to content
Draft
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
23 changes: 19 additions & 4 deletions .github/workflows/cloudflare-pages-shadow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ on:
- "src/**"
- "static/**"
push:
branches: [main]
# feat/blog-tiered-reader-ui: pushes to the held shadow branch auto-deploy
# the tss-shadow Pages project (tss.tinyland.dev) so the shadow host never
# goes stale. Prod (main -> transscendsurvival-org) is untouched.
branches: [main, feat/blog-tiered-reader-ui]
paths:
- ".github/workflows/cloudflare-pages-shadow.yml"
- "package.json"
Expand Down Expand Up @@ -98,16 +101,28 @@ jobs:
echo "::notice title=Cloudflare shadow deploy skipped::${message}"
fi

# Route: main -> transscendsurvival-org production (prod apex, unchanged);
# any other pushed branch -> the tss-shadow project's production
# deployment, which serves tss.tinyland.dev (the shadow host).
project="${CLOUDFLARE_PAGES_PROJECT_NAME}"
pages_branch="${deploy_branch}"
if [ "${deploy_branch}" != "main" ]; then
project="tss-shadow"
pages_branch="main"
fi

{
echo "can_deploy=${can_deploy}"
echo "branch=${deploy_branch}"
echo "project=${project}"
echo "pages_branch=${pages_branch}"
} >> "$GITHUB_OUTPUT"

{
echo "## Cloudflare Pages Shadow"
echo ""
echo "- Project: \`${CLOUDFLARE_PAGES_PROJECT_NAME}\`"
echo "- Branch: \`${deploy_branch}\`"
echo "- Project: \`${project}\`"
echo "- Branch: \`${deploy_branch}\` -> pages branch \`${pages_branch}\`"
echo "- Deploy enabled: \`${can_deploy}\`"
} >> "$GITHUB_STEP_SUMMARY"

Expand All @@ -118,5 +133,5 @@ jobs:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
wranglerVersion: "4.95.0"
command: pages deploy build --project-name=${{ env.CLOUDFLARE_PAGES_PROJECT_NAME }} --branch=${{ steps.cloudflare.outputs.branch }} --commit-dirty=true
command: pages deploy build --project-name=${{ steps.cloudflare.outputs.project }} --branch=${{ steps.cloudflare.outputs.pages_branch }} --commit-dirty=true
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
11 changes: 7 additions & 4 deletions e2e/visual-qa.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,16 @@ test.describe('Homepage — visual QA across viewports', () => {
});

test('navigation is accessible', async ({ page }) => {
if (vp.width >= 768) {
// Desktop nav: visible inline links (md: breakpoint = 768px)
const desktopNav = page.locator('nav.hidden.md\\:flex');
if (vp.width >= 1024) {
// Desktop nav: visible inline links (lg: breakpoint = 1024px —
// with Stream+Pulse the full nav overflows below that, so
// tablet uses the drawer; see +layout.svelte nav comment)
const desktopNav = page.locator('nav.hidden.lg\\:flex');
await expect(desktopNav).toBeVisible();
await expect(desktopNav.getByRole('link', { name: 'Blog' })).toBeVisible();
await expect(desktopNav.getByRole('link', { name: 'Stream' })).toBeVisible();
} else {
// Mobile: hamburger button present
// Mobile + tablet: hamburger button present
const hamburger = page.getByRole('button', { name: 'Open navigation' });
await expect(hamburger).toBeVisible();
}
Expand Down
8 changes: 8 additions & 0 deletions src/lib/components/BlogArticle.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { PostEditorialTier } from '$lib/types';
import GiscusComments from '$lib/components/GiscusComments.svelte';
import TierBadge from '$lib/components/TierBadge.svelte';
import TableOfContents from '$lib/components/TableOfContents.svelte';
import Breadcrumbs from '$lib/components/Breadcrumbs.svelte';
import ProfileSidebar from '$lib/components/ProfileSidebar.svelte';
Expand Down Expand Up @@ -41,6 +43,7 @@
tags?: string[];
original_url?: string;
category?: string;
editorial_tier?: PostEditorialTier;
feature_image?: string;
};

Expand All @@ -66,6 +69,7 @@
description: activePost.description,
tags: activePost.tags,
category: activePost.category,
editorial_tier: activePost.editorial_tier,
feature_image: activePost.feature_image,
}
: data.metadata
Expand Down Expand Up @@ -314,6 +318,10 @@
<span>&middot;</span>
<span class="badge preset-outlined-surface-500 text-xs capitalize">{activeMetadata.category}</span>
{/if}
{#if activeMetadata.editorial_tier}
<span>&middot;</span>
<TierBadge tier={activeMetadata.editorial_tier} />
{/if}
</div>
{#if activeMetadata.tags?.length}
<div class="flex flex-wrap gap-2 mt-3">
Expand Down
7 changes: 6 additions & 1 deletion src/lib/components/BlogCard.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Post } from '$lib/types';
import TierBadge from '$lib/components/TierBadge.svelte';

// dimensions.json is generated by scripts/optimize-images.mjs at prebuild.
// Use import.meta.glob so the build doesn't fail if the file is missing.
Expand Down Expand Up @@ -73,9 +74,12 @@
</article>
{:else if post}
<article
data-tier={post.editorial_tier ?? undefined}
class="card glass p-0 overflow-hidden hover:ring-2 ring-primary-500 transition-all group {post.featured
? 'ring-1 ring-primary-300'
: ''}"
: post.editorial_tier === 'noteworthy'
? 'ring-1 ring-primary-400/50'
: ''}"
>
<a href="/blog/{post.slug}" class="block">
<!-- Feature image banner -->
Expand Down Expand Up @@ -137,6 +141,7 @@
>{post.category}</span
>
{/if}
<TierBadge tier={post.editorial_tier} />
</div>

<!-- Title -->
Expand Down
38 changes: 38 additions & 0 deletions src/lib/components/TierBadge.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<script lang="ts">
import type { PostEditorialTier } from '$lib/types';

// Reader-weight badge for the editorial taxonomy (docs/blog-editorial-taxonomy-2026-07-03.md).
// Tiers are IA/reader-weight ONLY — never an auth, privacy, or AP-delivery signal.
// Renders nothing when the tier is undefined ("not classified yet").
let { tier, class: klass = '' }: { tier?: PostEditorialTier | undefined; class?: string } = $props();

const TIER_LABELS: Record<PostEditorialTier, string> = {
noteworthy: 'Noteworthy',
'less-noteworthy': 'Less noteworthy',
};

// noteworthy gets a stronger accent tint; less-noteworthy stays muted/outlined.
const TIER_PRESETS: Record<PostEditorialTier, string> = {
noteworthy: 'preset-tonal-primary',
'less-noteworthy': 'preset-outlined-surface-500',
};
</script>

{#if tier}
<span
class="badge {TIER_PRESETS[tier]} text-xs font-medium {klass}"
data-tier={tier}
data-testid="tier-badge"
aria-label={`Editorial tier: ${TIER_LABELS[tier]}`}
title={`Editorial tier: ${TIER_LABELS[tier]}`}
>
{#if tier === 'noteworthy'}
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true"
><path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/></svg
>
{/if}
{TIER_LABELS[tier]}
</span>
{/if}
74 changes: 74 additions & 0 deletions src/lib/components/TierBadge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { render } from 'svelte/server';
import type { Post } from '$lib/types';
import { POST_EDITORIAL_TIERS } from '$lib/types';
import TierBadge from './TierBadge.svelte';
import BlogCard from './BlogCard.svelte';

describe('TierBadge', () => {
it('renders a labelled badge for the noteworthy tier', () => {
const { html } = render(TierBadge, { props: { tier: 'noteworthy' } });

expect(html).toContain('data-testid="tier-badge"');
expect(html).toContain('data-tier="noteworthy"');
expect(html).toContain('Noteworthy');
expect(html).toContain('aria-label="Editorial tier: Noteworthy"');
});

it('renders a labelled badge for the less-noteworthy tier', () => {
const { html } = render(TierBadge, { props: { tier: 'less-noteworthy' } });

expect(html).toContain('data-testid="tier-badge"');
expect(html).toContain('data-tier="less-noteworthy"');
expect(html).toContain('Less noteworthy');
expect(html).toContain('aria-label="Editorial tier: Less noteworthy"');
});

it('renders a badge with an aria-label for every declared tier', () => {
for (const tier of POST_EDITORIAL_TIERS) {
const { html } = render(TierBadge, { props: { tier } });
expect(html).toContain('aria-label="Editorial tier:');
expect(html).toContain(`data-tier="${tier}"`);
}
});

it('renders nothing when the tier is undefined', () => {
const { html } = render(TierBadge, { props: { tier: undefined } });

expect(html).not.toContain('tier-badge');
expect(html).not.toContain('aria-label');
expect(html.replace(/<!--[\s\S]*?-->/g, '').trim()).toBe('');
});
});

function makePost(overrides: Partial<Post> = {}): Post {
return {
title: 'A Test Post',
slug: 'a-test-post',
date: '2026-03-04',
description: 'A description for the card.',
tags: ['test'],
published: true,
...overrides,
};
}

describe('BlogCard editorial tier surface', () => {
it('shows the tier badge when the post carries a tier', () => {
const { html } = render(BlogCard, {
props: { post: makePost({ editorial_tier: 'noteworthy' }) },
});

expect(html).toContain('data-testid="tier-badge"');
expect(html).toContain('aria-label="Editorial tier: Noteworthy"');
});

it('omits the tier badge entirely when the post has no tier', () => {
const { html } = render(BlogCard, {
props: { post: makePost() },
});

expect(html).toContain('A Test Post');
expect(html).not.toContain('tier-badge');
});
});
9 changes: 7 additions & 2 deletions src/lib/components/pulse/PulseBirdCard.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import type { PublicPulseItem } from '@blog/pulse-core/schema';
import TierBadge from '$lib/components/TierBadge.svelte';

let { item }: { item: PublicPulseItem } = $props();

Expand All @@ -14,8 +15,12 @@
</script>

<article class="card preset-outlined-success-500 p-4 space-y-2">
<header class="flex items-center justify-between text-sm text-surface-600-400">
<span aria-label="kind">🐦 bird sighting</span>
<header class="flex items-center justify-between gap-3 text-sm text-surface-600-400">
<span class="flex items-center gap-2">
<span aria-label="kind">🐦 bird sighting</span>
<!-- Reader-weight tier, display-only. Renders nothing when salience is absent. -->
<TierBadge tier={item.salience} />
</span>
<time datetime={item.occurredAt}>{formatted}</time>
</header>
{#if sighting}
Expand Down
24 changes: 24 additions & 0 deletions src/lib/components/pulse/PulseCards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,30 @@ describe('Pulse cards', () => {
});
});

describe('Pulse card editorial salience surface', () => {
it('shows a TierBadge on a note card only when salience is present', () => {
const withSalience = render(PulseNoteCard, {
props: { item: { ...noteItem, salience: 'noteworthy' } },
});
expect(withSalience.html).toContain('data-testid="tier-badge"');
expect(withSalience.html).toContain('aria-label="Editorial tier: Noteworthy"');

const withoutSalience = render(PulseNoteCard, { props: { item: noteItem } });
expect(withoutSalience.html).not.toContain('tier-badge');
});

it('shows a TierBadge on a bird card only when salience is present', () => {
const withSalience = render(PulseBirdCard, {
props: { item: { ...birdItem, salience: 'less-noteworthy' } },
});
expect(withSalience.html).toContain('data-testid="tier-badge"');
expect(withSalience.html).toContain('data-tier="less-noteworthy"');

const withoutSalience = render(PulseBirdCard, { props: { item: birdItem } });
expect(withoutSalience.html).not.toContain('tier-badge');
});
});

describe('Pulse lab policy result rendering', () => {
it('renders allowed projection decisions', () => {
const decision: PolicyDecision = { allowed: true, item: noteItem };
Expand Down
9 changes: 7 additions & 2 deletions src/lib/components/pulse/PulseNoteCard.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import type { PublicPulseItem } from '@blog/pulse-core/schema';
import TierBadge from '$lib/components/TierBadge.svelte';

let { item }: { item: PublicPulseItem } = $props();

Expand All @@ -12,8 +13,12 @@
</script>

<article class="card preset-outlined-surface-500 p-4 space-y-2">
<header class="flex items-center justify-between text-sm text-surface-600-400">
<span aria-label="kind">💬 note</span>
<header class="flex items-center justify-between gap-3 text-sm text-surface-600-400">
<span class="flex items-center gap-2">
<span aria-label="kind">💬 note</span>
<!-- Reader-weight tier, display-only. Renders nothing when salience is absent. -->
<TierBadge tier={item.salience} />
</span>
<time datetime={item.occurredAt}>{formatted}</time>
</header>
<p class="leading-relaxed whitespace-pre-wrap">{item.content}</p>
Expand Down
Loading
Loading