claudecode-omc 5.6.6 → 5.6.7

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.
Files changed (58) hide show
  1. package/.local/skills/h5-to-swiftui/SKILL.md +201 -0
  2. package/.local/skills/h5-to-swiftui/assets/calibration/README.md +176 -0
  3. package/.local/skills/h5-to-swiftui/assets/calibration/h5-twin/index.html +52 -0
  4. package/.local/skills/h5-to-swiftui/assets/calibration/h5-twin/style.css +133 -0
  5. package/.local/skills/h5-to-swiftui/assets/calibration/swiftui-twin/Package.swift +26 -0
  6. package/.local/skills/h5-to-swiftui/assets/calibration/swiftui-twin/Sources/CalibrationScreen/CalibrationScreen.swift +142 -0
  7. package/.local/skills/h5-to-swiftui/assets/calibration/swiftui-twin-divergent/Package.swift +32 -0
  8. package/.local/skills/h5-to-swiftui/assets/calibration/swiftui-twin-divergent/Sources/CalibrationScreenDivergent/CalibrationScreenDivergent.swift +122 -0
  9. package/.local/skills/h5-to-swiftui/assets/calibration/tokens.json +42 -0
  10. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/index.html +14 -0
  11. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/package.json +20 -0
  12. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/public/api/articles/001.json +96 -0
  13. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/public/api/articles/index.json +89 -0
  14. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/App.jsx +22 -0
  15. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/App.module.css +11 -0
  16. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/ArticleCard.jsx +53 -0
  17. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/ArticleCard.module.css +139 -0
  18. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/NavBar.jsx +37 -0
  19. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/NavBar.module.css +72 -0
  20. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/TagCloud.jsx +30 -0
  21. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/TagCloud.module.css +50 -0
  22. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/TrendChart.jsx +159 -0
  23. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/TrendChart.module.css +21 -0
  24. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/main.jsx +12 -0
  25. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/screens/ArticleScreen.jsx +182 -0
  26. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/screens/ArticleScreen.module.css +294 -0
  27. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/screens/FeedScreen.jsx +147 -0
  28. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/screens/FeedScreen.module.css +161 -0
  29. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/styles/global.css +50 -0
  30. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/styles/tokens.css +103 -0
  31. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/vite.config.js +6 -0
  32. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/data/tasks.js +67 -0
  33. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/index.html +26 -0
  34. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/router.js +73 -0
  35. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/screens/detail.js +164 -0
  36. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/screens/home.js +53 -0
  37. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/screens/list.js +87 -0
  38. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/styles/app.css +342 -0
  39. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/styles/tokens.css +68 -0
  40. package/.local/skills/h5-to-swiftui/references/css-to-swiftui-map.md +205 -0
  41. package/.local/skills/h5-to-swiftui/references/design-token-extraction.md +209 -0
  42. package/.local/skills/h5-to-swiftui/references/high-risk-triage.md +209 -0
  43. package/.local/skills/h5-to-swiftui/references/render-equivalence-calibration.md +193 -0
  44. package/.local/skills/h5-to-swiftui/references/stack-detection.md +160 -0
  45. package/.local/skills/h5-to-swiftui/references/visual-diff-loop-protocol.md +365 -0
  46. package/.local/skills/h5-to-swiftui/scripts/_calib-consts.mjs +150 -0
  47. package/.local/skills/h5-to-swiftui/scripts/_imglib.mjs +547 -0
  48. package/.local/skills/h5-to-swiftui/scripts/_provenance.mjs +123 -0
  49. package/.local/skills/h5-to-swiftui/scripts/calibrate-render.mjs +625 -0
  50. package/.local/skills/h5-to-swiftui/scripts/capture-reference.mjs +386 -0
  51. package/.local/skills/h5-to-swiftui/scripts/detect-stack.mjs +305 -0
  52. package/.local/skills/h5-to-swiftui/scripts/evaluate-convergence.mjs +1093 -0
  53. package/.local/skills/h5-to-swiftui/scripts/extract-tokens.mjs +600 -0
  54. package/.local/skills/h5-to-swiftui/scripts/mark-overlay.mjs +379 -0
  55. package/.local/skills/h5-to-swiftui/scripts/pixel-diff.mjs +530 -0
  56. package/.local/skills/h5-to-swiftui/scripts/sim-screenshot.sh +544 -0
  57. package/bundled/manifest.json +1 -1
  58. package/package.json +1 -1
@@ -0,0 +1,139 @@
1
+ .card {
2
+ background-color: var(--color-bg-card);
3
+ border-radius: var(--radius-lg);
4
+ box-shadow: var(--shadow-card);
5
+ overflow: hidden;
6
+ cursor: pointer;
7
+ transition: box-shadow var(--duration-normal) var(--easing-default),
8
+ transform var(--duration-normal) var(--easing-default);
9
+ display: flex;
10
+ flex-direction: column;
11
+ }
12
+
13
+ .card:hover {
14
+ box-shadow: var(--shadow-elevated);
15
+ transform: translateY(-2px);
16
+ }
17
+
18
+ .card:focus-visible {
19
+ outline: 2px solid var(--color-accent);
20
+ outline-offset: 2px;
21
+ }
22
+
23
+ .imagePlaceholder {
24
+ width: 100%;
25
+ aspect-ratio: 16 / 9;
26
+ background-color: var(--color-bg-secondary);
27
+ display: flex;
28
+ align-items: center;
29
+ justify-content: center;
30
+ overflow: hidden;
31
+ border-bottom: 1px solid var(--color-divider);
32
+ }
33
+
34
+ .imagePlaceholderText {
35
+ font-size: var(--font-size-xs);
36
+ color: var(--color-text-caption);
37
+ text-align: center;
38
+ padding: var(--space-2);
39
+ }
40
+
41
+ .body {
42
+ padding: var(--space-5);
43
+ display: flex;
44
+ flex-direction: column;
45
+ gap: var(--space-3);
46
+ flex-grow: 1;
47
+ }
48
+
49
+ .meta {
50
+ display: flex;
51
+ flex-direction: row;
52
+ align-items: center;
53
+ justify-content: space-between;
54
+ }
55
+
56
+ .tag {
57
+ font-size: var(--font-size-xs);
58
+ font-weight: var(--font-weight-semibold);
59
+ color: var(--color-text-accent);
60
+ text-transform: uppercase;
61
+ letter-spacing: 0.6px;
62
+ }
63
+
64
+ .readingTime {
65
+ font-size: var(--font-size-xs);
66
+ color: var(--color-text-caption);
67
+ }
68
+
69
+ .title {
70
+ font-family: var(--font-family-display);
71
+ font-size: var(--font-size-subhead);
72
+ font-weight: var(--font-weight-bold);
73
+ color: var(--color-text-primary);
74
+ line-height: var(--line-height-tight);
75
+ letter-spacing: -0.3px;
76
+ }
77
+
78
+ .summary {
79
+ font-size: var(--font-size-body);
80
+ color: var(--color-text-secondary);
81
+ line-height: var(--line-height-normal);
82
+ display: -webkit-box;
83
+ -webkit-line-clamp: 3;
84
+ -webkit-box-orient: vertical;
85
+ overflow: hidden;
86
+ }
87
+
88
+ .footer {
89
+ display: flex;
90
+ flex-direction: row;
91
+ align-items: center;
92
+ justify-content: space-between;
93
+ margin-top: auto;
94
+ padding-top: var(--space-3);
95
+ border-top: 1px solid var(--color-divider);
96
+ }
97
+
98
+ .authorArea {
99
+ display: flex;
100
+ flex-direction: row;
101
+ align-items: center;
102
+ gap: var(--space-2);
103
+ }
104
+
105
+ .avatar {
106
+ width: 32px;
107
+ height: 32px;
108
+ border-radius: var(--radius-full);
109
+ background-color: var(--color-accent);
110
+ color: var(--color-text-on-accent);
111
+ font-size: var(--font-size-sm);
112
+ font-weight: var(--font-weight-bold);
113
+ display: flex;
114
+ align-items: center;
115
+ justify-content: center;
116
+ flex-shrink: 0;
117
+ }
118
+
119
+ .authorInfo {
120
+ display: flex;
121
+ flex-direction: column;
122
+ gap: 1px;
123
+ }
124
+
125
+ .authorName {
126
+ font-size: var(--font-size-sm);
127
+ font-weight: var(--font-weight-semibold);
128
+ color: var(--color-text-primary);
129
+ }
130
+
131
+ .authorRole {
132
+ font-size: var(--font-size-xs);
133
+ color: var(--color-text-caption);
134
+ }
135
+
136
+ .date {
137
+ font-size: var(--font-size-xs);
138
+ color: var(--color-text-caption);
139
+ }
@@ -0,0 +1,37 @@
1
+ import React from 'react';
2
+ import { useNavigate, useLocation } from 'react-router-dom';
3
+ import styles from './NavBar.module.css';
4
+
5
+ const TABS = [
6
+ { id: 'feed', label: 'Feed', path: '/' },
7
+ ];
8
+
9
+ export default function NavBar({ activeTab, onTabChange }) {
10
+ const navigate = useNavigate();
11
+ const location = useLocation();
12
+
13
+ function handleTab(tab) {
14
+ onTabChange(tab.id);
15
+ navigate(tab.path);
16
+ }
17
+
18
+ return (
19
+ <header className={styles.navbar}>
20
+ <div className={styles.logoArea}>
21
+ <span className={styles.logoMark}>C</span>
22
+ <span className={styles.logoText}>Chronicle</span>
23
+ </div>
24
+ <nav className={styles.tabs}>
25
+ {TABS.map(tab => (
26
+ <button
27
+ key={tab.id}
28
+ className={`${styles.tab} ${location.pathname === tab.path ? styles.tabActive : ''}`}
29
+ onClick={() => handleTab(tab)}
30
+ >
31
+ {tab.label}
32
+ </button>
33
+ ))}
34
+ </nav>
35
+ </header>
36
+ );
37
+ }
@@ -0,0 +1,72 @@
1
+ .navbar {
2
+ display: flex;
3
+ flex-direction: row;
4
+ align-items: center;
5
+ justify-content: space-between;
6
+ padding: var(--space-3) var(--space-6);
7
+ background-color: var(--color-bg-primary);
8
+ border-bottom: 1px solid var(--color-border);
9
+ position: sticky;
10
+ top: 0;
11
+ z-index: 100;
12
+ }
13
+
14
+ .logoArea {
15
+ display: flex;
16
+ flex-direction: row;
17
+ align-items: center;
18
+ gap: var(--space-2);
19
+ }
20
+
21
+ .logoMark {
22
+ width: 28px;
23
+ height: 28px;
24
+ border-radius: var(--radius-sm);
25
+ background-color: var(--color-accent);
26
+ color: var(--color-text-on-accent);
27
+ font-family: var(--font-family-display);
28
+ font-size: var(--font-size-sm);
29
+ font-weight: var(--font-weight-bold);
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: center;
33
+ }
34
+
35
+ .logoText {
36
+ font-family: var(--font-family-display);
37
+ font-size: var(--font-size-headline);
38
+ font-weight: var(--font-weight-semibold);
39
+ color: var(--color-text-primary);
40
+ letter-spacing: -0.5px;
41
+ }
42
+
43
+ .tabs {
44
+ display: flex;
45
+ flex-direction: row;
46
+ gap: var(--space-1);
47
+ }
48
+
49
+ .tab {
50
+ padding: var(--space-2) var(--space-4);
51
+ border-radius: var(--radius-full);
52
+ font-size: var(--font-size-sm);
53
+ font-weight: var(--font-weight-medium);
54
+ color: var(--color-text-secondary);
55
+ transition: background-color var(--duration-fast) var(--easing-default),
56
+ color var(--duration-fast) var(--easing-default);
57
+ }
58
+
59
+ .tab:hover {
60
+ background-color: var(--color-bg-secondary);
61
+ color: var(--color-text-primary);
62
+ }
63
+
64
+ .tabActive {
65
+ background-color: var(--color-accent);
66
+ color: var(--color-text-on-accent);
67
+ }
68
+
69
+ .tabActive:hover {
70
+ background-color: var(--color-accent-hover);
71
+ color: var(--color-text-on-accent);
72
+ }
@@ -0,0 +1,30 @@
1
+ import React from 'react';
2
+ import styles from './TagCloud.module.css';
3
+
4
+ /**
5
+ * TagCloud — a flex-wrap tag gallery with non-uniform item widths.
6
+ *
7
+ * CSS uses `flex-wrap: wrap` with variable-length tag labels.
8
+ * This pattern has NO stock SwiftUI equivalent:
9
+ * "flex-wrap: wrap → HStack never wraps; LazyHGrid/LazyVGrid are scroll
10
+ * containers, not inline wrapping layouts" (css-to-swiftui-map.md)
11
+ *
12
+ * CUSTOM-LAYOUT: flex-wrap:wrap — the variable-width tags must reflow onto
13
+ * multiple rows at arbitrary breakpoints determined by available width, which
14
+ * HStack cannot do. SwiftUI port requires the FlowLayout custom Layout protocol.
15
+ */
16
+ export default function TagCloud({ tags, onTagClick, selectedTag }) {
17
+ return (
18
+ <div className={styles.cloud}>
19
+ {tags.map(tag => (
20
+ <button
21
+ key={tag}
22
+ className={`${styles.tag} ${selectedTag === tag ? styles.tagSelected : ''}`}
23
+ onClick={() => onTagClick(tag === selectedTag ? null : tag)}
24
+ >
25
+ {tag}
26
+ </button>
27
+ ))}
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1,50 @@
1
+ /* CUSTOM-LAYOUT: flex-wrap:wrap
2
+ *
3
+ * flex-wrap: wrap causes tags with non-uniform widths to reflow onto multiple
4
+ * rows. The break points are data-driven (label length) and width-driven
5
+ * (viewport). HStack in SwiftUI never wraps — this MUST use a custom Layout
6
+ * (FlowLayout) implementing the Layout protocol (iOS 16+).
7
+ *
8
+ * Trigger quote from css-to-swiftui-map.md:
9
+ * "flex-wrap: wrap → Custom Layout protocol (iOS 16+) — FlowLayout
10
+ * No LazyHGrid/LazyVGrid equivalent for true wrapping"
11
+ */
12
+ .cloud {
13
+ display: flex;
14
+ flex-direction: row;
15
+ flex-wrap: wrap; /* CUSTOM-LAYOUT: flex-wrap:wrap */
16
+ gap: var(--space-2) var(--space-2);
17
+ padding: var(--space-4) 0;
18
+ }
19
+
20
+ .tag {
21
+ display: inline-flex;
22
+ align-items: center;
23
+ padding: var(--space-1) var(--space-3);
24
+ border-radius: var(--radius-full);
25
+ background-color: var(--color-bg-tag);
26
+ color: var(--color-text-accent);
27
+ font-size: var(--font-size-sm);
28
+ font-weight: var(--font-weight-medium);
29
+ white-space: nowrap;
30
+ border: 1px solid transparent;
31
+ transition: background-color var(--duration-fast) var(--easing-default),
32
+ border-color var(--duration-fast) var(--easing-default);
33
+ cursor: pointer;
34
+ }
35
+
36
+ .tag:hover {
37
+ background-color: var(--color-bg-secondary);
38
+ border-color: var(--color-border);
39
+ }
40
+
41
+ .tagSelected {
42
+ background-color: var(--color-accent);
43
+ color: var(--color-text-on-accent);
44
+ border-color: var(--color-accent);
45
+ }
46
+
47
+ .tagSelected:hover {
48
+ background-color: var(--color-accent-hover);
49
+ border-color: var(--color-accent-hover);
50
+ }
@@ -0,0 +1,159 @@
1
+ import React, { useRef, useEffect } from 'react';
2
+ import styles from './TrendChart.module.css';
3
+
4
+ /**
5
+ * TrendChart — WebGL sparkline chart rendered on a <canvas> element.
6
+ *
7
+ * {/* TIER3: WebGL canvas — uses WebGLRenderingContext to draw a sparkline.
8
+ * The rendering pipeline is entirely GPU-driven via custom GLSL shaders.
9
+ * There is NO SwiftUI equivalent for arbitrary WebGL content.
10
+ * SwiftUI port options:
11
+ * (a) Replace with a native Swift Charts (iOS 16+) line mark — preferred
12
+ * if data semantics are equivalent.
13
+ * (b) Wrap in WKWebView if exact visual must be preserved (accepts
14
+ * partial fidelity loss: no ProMotion, extra process, no Dynamic Type).
15
+ * Risk triage MUST flag this surface and NOT silently generate wrong code.
16
+ * */
17
+ export default function TrendChart({ data, label, color = '#0071e3' }) {
18
+ const canvasRef = useRef(null);
19
+
20
+ useEffect(() => {
21
+ const canvas = canvasRef.current;
22
+ if (!canvas) return;
23
+
24
+ // Attempt WebGL; fall back to 2D canvas if unavailable (e.g. jsdom in tests)
25
+ const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
26
+
27
+ if (!gl) {
28
+ // Graceful 2D fallback (no WebGL in test env)
29
+ const ctx = canvas.getContext('2d');
30
+ if (!ctx || !data?.length) return;
31
+ drawFallback2D(ctx, canvas, data, color);
32
+ return;
33
+ }
34
+
35
+ // WebGL sparkline — minimal shader pair
36
+ const vsSource = `
37
+ attribute vec2 a_position;
38
+ uniform vec2 u_resolution;
39
+ void main() {
40
+ vec2 clip = (a_position / u_resolution) * 2.0 - 1.0;
41
+ gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
42
+ }
43
+ `;
44
+
45
+ const fsSource = `
46
+ precision mediump float;
47
+ uniform vec4 u_color;
48
+ void main() {
49
+ gl_FragColor = u_color;
50
+ }
51
+ `;
52
+
53
+ const vs = compileShader(gl, gl.VERTEX_SHADER, vsSource);
54
+ const fs = compileShader(gl, gl.FRAGMENT_SHADER, fsSource);
55
+ if (!vs || !fs) return;
56
+
57
+ const program = gl.createProgram();
58
+ gl.attachShader(program, vs);
59
+ gl.attachShader(program, fs);
60
+ gl.linkProgram(program);
61
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return;
62
+
63
+ gl.useProgram(program);
64
+
65
+ // Map data to canvas coordinates
66
+ const w = canvas.width;
67
+ const h = canvas.height;
68
+ const min = Math.min(...data);
69
+ const max = Math.max(...data);
70
+ const range = max - min || 1;
71
+ const pad = 8;
72
+
73
+ const vertices = new Float32Array(data.length * 2);
74
+ data.forEach((v, i) => {
75
+ vertices[i * 2] = pad + (i / (data.length - 1)) * (w - pad * 2);
76
+ vertices[i * 2 + 1] = h - pad - ((v - min) / range) * (h - pad * 2);
77
+ });
78
+
79
+ const buf = gl.createBuffer();
80
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
81
+ gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
82
+
83
+ const posLoc = gl.getAttribLocation(program, 'a_position');
84
+ gl.enableVertexAttribArray(posLoc);
85
+ gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
86
+
87
+ const resLoc = gl.getUniformLocation(program, 'u_resolution');
88
+ gl.uniform2f(resLoc, w, h);
89
+
90
+ const colLoc = gl.getUniformLocation(program, 'u_color');
91
+ const [r, g, b] = hexToRgb(color);
92
+ gl.uniform4f(colLoc, r, g, b, 1.0);
93
+
94
+ gl.viewport(0, 0, w, h);
95
+ gl.clearColor(0, 0, 0, 0);
96
+ gl.clear(gl.COLOR_BUFFER_BIT);
97
+ gl.lineWidth(2);
98
+ gl.drawArrays(gl.LINE_STRIP, 0, data.length);
99
+
100
+ return () => {
101
+ gl.deleteProgram(program);
102
+ gl.deleteBuffer(buf);
103
+ };
104
+ }, [data, color]);
105
+
106
+ return (
107
+ <div className={styles.chartWrapper}>
108
+ {label && <span className={styles.chartLabel}>{label}</span>}
109
+ <canvas
110
+ ref={canvasRef}
111
+ width={280}
112
+ height={80}
113
+ className={styles.canvas}
114
+ aria-label={`Trend chart: ${label}`}
115
+ role="img"
116
+ />
117
+ </div>
118
+ );
119
+ }
120
+
121
+ // ── helpers ──────────────────────────────────────────────────────────────────
122
+
123
+ function compileShader(gl, type, source) {
124
+ const shader = gl.createShader(type);
125
+ gl.shaderSource(shader, source);
126
+ gl.compileShader(shader);
127
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
128
+ gl.deleteShader(shader);
129
+ return null;
130
+ }
131
+ return shader;
132
+ }
133
+
134
+ function hexToRgb(hex) {
135
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
136
+ return result
137
+ ? [parseInt(result[1], 16) / 255, parseInt(result[2], 16) / 255, parseInt(result[3], 16) / 255]
138
+ : [0, 0.44, 0.89];
139
+ }
140
+
141
+ function drawFallback2D(ctx, canvas, data, color) {
142
+ const w = canvas.width;
143
+ const h = canvas.height;
144
+ const min = Math.min(...data);
145
+ const max = Math.max(...data);
146
+ const range = max - min || 1;
147
+ const pad = 8;
148
+
149
+ ctx.clearRect(0, 0, w, h);
150
+ ctx.beginPath();
151
+ ctx.strokeStyle = color;
152
+ ctx.lineWidth = 2;
153
+ data.forEach((v, i) => {
154
+ const x = pad + (i / (data.length - 1)) * (w - pad * 2);
155
+ const y = h - pad - ((v - min) / range) * (h - pad * 2);
156
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
157
+ });
158
+ ctx.stroke();
159
+ }
@@ -0,0 +1,21 @@
1
+ .chartWrapper {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--space-2);
5
+ }
6
+
7
+ .chartLabel {
8
+ font-size: var(--font-size-xs);
9
+ font-weight: var(--font-weight-semibold);
10
+ color: var(--color-text-secondary);
11
+ text-transform: uppercase;
12
+ letter-spacing: 0.5px;
13
+ }
14
+
15
+ .canvas {
16
+ display: block;
17
+ border-radius: var(--radius-sm);
18
+ background-color: var(--color-bg-secondary);
19
+ width: 100%;
20
+ height: 80px;
21
+ }
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import { BrowserRouter } from 'react-router-dom';
4
+ import App from './App.jsx';
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')).render(
7
+ <React.StrictMode>
8
+ <BrowserRouter>
9
+ <App />
10
+ </BrowserRouter>
11
+ </React.StrictMode>
12
+ );
@@ -0,0 +1,182 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useParams, useNavigate } from 'react-router-dom';
3
+ import styles from './ArticleScreen.module.css';
4
+
5
+ export default function ArticleScreen() {
6
+ const { id } = useParams();
7
+ const navigate = useNavigate();
8
+ const [article, setArticle] = useState(null);
9
+ const [loading, setLoading] = useState(true);
10
+ const [error, setError] = useState(null);
11
+
12
+ useEffect(() => {
13
+ let cancelled = false;
14
+
15
+ async function loadArticle() {
16
+ setLoading(true);
17
+ setError(null);
18
+ try {
19
+ const res = await fetch(`/api/articles/${id}`);
20
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
21
+ const data = await res.json();
22
+ if (!cancelled) setArticle(data);
23
+ } catch (err) {
24
+ if (!cancelled) setError(err.message);
25
+ } finally {
26
+ if (!cancelled) setLoading(false);
27
+ }
28
+ }
29
+
30
+ loadArticle();
31
+ return () => { cancelled = true; };
32
+ }, [id]);
33
+
34
+ if (loading) {
35
+ return (
36
+ <div className={styles.stateContainer}>
37
+ <div className={styles.spinner} aria-label="Loading article" />
38
+ <p className={styles.stateText}>Loading article…</p>
39
+ </div>
40
+ );
41
+ }
42
+
43
+ if (error) {
44
+ return (
45
+ <div className={styles.stateContainer} role="alert">
46
+ <p className={styles.errorHeading}>Could not load article</p>
47
+ <p className={styles.stateText}>{error}</p>
48
+ <button className={styles.backButton} onClick={() => navigate('/')}>
49
+ ← Back to feed
50
+ </button>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ if (!article) return null;
56
+
57
+ const {
58
+ title,
59
+ subtitle,
60
+ author,
61
+ publishedAt,
62
+ readingTime,
63
+ tag,
64
+ body,
65
+ } = article;
66
+
67
+ const formattedDate = new Date(publishedAt).toLocaleDateString('en-US', {
68
+ month: 'long',
69
+ day: 'numeric',
70
+ year: 'numeric',
71
+ });
72
+
73
+ return (
74
+ <article className={styles.article}>
75
+ {/* ── Back navigation ───────────────────────────────────── */}
76
+ <div className={styles.topBar}>
77
+ <button className={styles.backButton} onClick={() => navigate('/')}>
78
+ ← Chronicle
79
+ </button>
80
+ <span className={styles.tagChip}>{tag}</span>
81
+ </div>
82
+
83
+ {/* ── Article header ────────────────────────────────────── */}
84
+ <header className={styles.header}>
85
+ <h1 className={styles.title}>{title}</h1>
86
+ {subtitle && (
87
+ <p className={styles.subtitle}>{subtitle}</p>
88
+ )}
89
+
90
+ <div className={styles.byline}>
91
+ <div className={styles.avatarLg} aria-hidden="true">
92
+ {author.name.slice(0, 1)}
93
+ </div>
94
+ <div className={styles.bylineText}>
95
+ <span className={styles.authorName}>{author.name}</span>
96
+ <span className={styles.authorMeta}>
97
+ {author.role} · <time dateTime={publishedAt}>{formattedDate}</time> · {readingTime} min read
98
+ </span>
99
+ </div>
100
+ </div>
101
+ </header>
102
+
103
+ {/* ── Body — text-heavy, multi-typographic-level region ─── */}
104
+ {/*
105
+ * TEXT REGION NOTE (skill stage 2):
106
+ * This body region is intentionally text-heavy with multiple typographic
107
+ * levels (h2 headlines, pull-quote, body paragraphs, code block, caption).
108
+ * The skill must apply layout-box IoU + token-color ΔE for region
109
+ * comparison, NOT glyph SSIM, because CoreText metrics diverge from
110
+ * WebKit at identical px declarations (see stack-detection.md reason 4).
111
+ */}
112
+ <div className={styles.body}>
113
+ {body.map((block, idx) => (
114
+ <ArticleBlock key={idx} block={block} />
115
+ ))}
116
+ </div>
117
+
118
+ {/* ── Article footer ────────────────────────────────────── */}
119
+ <footer className={styles.footer}>
120
+ <div className={styles.footerDivider} />
121
+ <div className={styles.authorCard}>
122
+ <div className={styles.avatarLg} aria-hidden="true">
123
+ {author.name.slice(0, 1)}
124
+ </div>
125
+ <div className={styles.authorCardText}>
126
+ <span className={styles.authorCardName}>{author.name}</span>
127
+ <span className={styles.authorCardRole}>{author.role}</span>
128
+ {author.bio && (
129
+ <p className={styles.authorBio}>{author.bio}</p>
130
+ )}
131
+ </div>
132
+ </div>
133
+ </footer>
134
+ </article>
135
+ );
136
+ }
137
+
138
+ // ── Block renderer ────────────────────────────────────────────────────────────
139
+
140
+ function ArticleBlock({ block }) {
141
+ switch (block.type) {
142
+ case 'paragraph':
143
+ return <p className={styles.paragraph}>{block.text}</p>;
144
+
145
+ case 'heading':
146
+ return <h2 className={styles.heading2}>{block.text}</h2>;
147
+
148
+ case 'subheading':
149
+ return <h3 className={styles.heading3}>{block.text}</h3>;
150
+
151
+ case 'pullquote':
152
+ return (
153
+ <blockquote className={styles.pullquote}>
154
+ <p className={styles.pullquoteText}>{block.text}</p>
155
+ {block.attribution && (
156
+ <cite className={styles.pullquoteAttribution}>{block.attribution}</cite>
157
+ )}
158
+ </blockquote>
159
+ );
160
+
161
+ case 'code':
162
+ return (
163
+ <figure className={styles.codeFigure}>
164
+ {block.caption && (
165
+ <figcaption className={styles.codeCaption}>{block.caption}</figcaption>
166
+ )}
167
+ <pre className={styles.pre}>
168
+ <code className={styles.code}>{block.text}</code>
169
+ </pre>
170
+ </figure>
171
+ );
172
+
173
+ case 'caption':
174
+ return <p className={styles.caption}>{block.text}</p>;
175
+
176
+ case 'divider':
177
+ return <hr className={styles.divider} />;
178
+
179
+ default:
180
+ return null;
181
+ }
182
+ }