diff --git a/.github/workflows/home-first-read.yml b/.github/workflows/home-first-read.yml index 40258d6d..f481c770 100644 --- a/.github/workflows/home-first-read.yml +++ b/.github/workflows/home-first-read.yml @@ -8,6 +8,10 @@ on: - 'tests/test_browser*' - 'tests/test_thread*' - 'scripts/check_home_first_read_browser.py' + - 'scripts/check_ascii_home_browser.py' + - 'src/msg/data/ascii-runtime.js' + - 'vendor/ascii/**' + - 'tests/test_ascii_art.py' - '.github/workflows/home-first-read.yml' push: branches: [main] @@ -18,6 +22,10 @@ on: - 'tests/test_browser*' - 'tests/test_thread*' - 'scripts/check_home_first_read_browser.py' + - 'scripts/check_ascii_home_browser.py' + - 'src/msg/data/ascii-runtime.js' + - 'vendor/ascii/**' + - 'tests/test_ascii_art.py' - '.github/workflows/home-first-read.yml' workflow_dispatch: permissions: @@ -50,6 +58,13 @@ jobs: with: python-version: '3.15' allow-prereleases: true + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.4.0' + - name: Verify the committed ASCII runtime matches its source + run: | + bun scripts/build_ascii.mjs + git diff --exit-code -- src/msg/data/ascii-runtime.js - name: Install test tools run: | sudo apt-get update -qq @@ -60,15 +75,17 @@ jobs: - name: Verify first reads, authority and existing browser routes run: | mkdir -p artifacts - uv run pytest tests/test_home_first_read.py tests/test_home_cache.py tests/test_browser_refinement.py tests/test_browser_html.py tests/test_browser_languages.py tests/test_thread_panel.py tests/test_thread_panel_http.py tests/test_board_presentation.py --junitxml=artifacts/browser-first-read-tests.xml + uv run pytest tests/test_home_first_read.py tests/test_home_cache.py tests/test_browser_refinement.py tests/test_browser_html.py tests/test_browser_languages.py tests/test_thread_panel.py tests/test_thread_panel_http.py tests/test_board_presentation.py tests/test_ascii_art.py tests/test_public_board.py tests/test_public_board_details.py --junitxml=artifacts/browser-first-read-tests.xml - name: Verify real navigation and responsive rendering under CSP env: PYTHONPATH: src - run: uv run python scripts/check_home_first_read_browser.py + run: | + uv run python scripts/check_home_first_read_browser.py + uv run python scripts/check_ascii_home_browser.py - name: Check changed source and tests run: | - uv run ruff check src/msg/transports/{browser_style,home_page,http_routes,thread_browser}.py tests/{test_home_first_read,test_browser_refinement,test_thread_panel}.py scripts/check_home_first_read_browser.py - uv run ruff format --check src/msg/transports/{browser_style,home_page,http_routes,thread_browser}.py tests/{test_home_first_read,test_browser_refinement,test_thread_panel}.py scripts/check_home_first_read_browser.py + uv run ruff check src/msg/transports/{ascii_art,public_board,browser_style,home_page,http_routes,thread_browser}.py tests/{test_ascii_art,test_home_first_read,test_browser_refinement,test_thread_panel}.py scripts/check_home_first_read_browser.py scripts/check_ascii_home_browser.py + uv run ruff format --check src/msg/transports/{ascii_art,public_board,browser_style,home_page,http_routes,thread_browser}.py tests/{test_ascii_art,test_home_first_read,test_browser_refinement,test_thread_panel}.py scripts/check_home_first_read_browser.py scripts/check_ascii_home_browser.py uv run python -W error -m compileall -q src git diff --check - uses: actions/upload-artifact@v7.0.1 diff --git a/scripts/check_ascii_home_browser.py b/scripts/check_ascii_home_browser.py new file mode 100644 index 00000000..43095ba5 --- /dev/null +++ b/scripts/check_ascii_home_browser.py @@ -0,0 +1,237 @@ +"""Exercise the real home renderer and CSP with isolated, synthetic account data.""" + +from __future__ import annotations + +import json +import os +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urlsplit + +from check_home_first_read_browser import DATA, POSTS +from playwright.sync_api import expect, sync_playwright + +from msg.plugins.public_board import default +from msg.transports.home_page import HOME_BROWSER_HEADERS, home_html + +OUTPUT = Path('artifacts/ascii-home') +ACCOUNT = {'name': '@fixture', 'groups': [{'name': '&public', 'path': '/&public'}]} +CUSTOM = 'Shared & public' + + +def render(path): + board = default() + account = None if path == '/guest' else ACCOUNT + if path == '/shared': + board.update(svg=CUSTOM, generation=1) + if path == '/long': + account = {**ACCOUNT, 'name': '@' + 'long-handle-' * 18} + board['text'] = ' ' + '长标题 Long text ' * 80 + return home_html( + DATA, + account=account, + login_enabled=True, + public_board=board, + post_index={'items': POSTS}, + ) + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def do_GET(self): + path = urlsplit(self.path).path + if path == '/favicon.png': + self.send_response(204) + self.end_headers() + return + image = path == '/_public-board/art.svg' + svg = CUSTOM if 'v=1' in self.path else default()['svg'] + payload = svg.encode() if image else render(path) + self.send_response(200) + self.send_header('Content-Type', 'image/svg+xml' if image else 'text/html; charset=utf-8') + for key, value in HOME_BROWSER_HEADERS.items(): + self.send_header(key, value) + self.send_header('Content-Length', str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + +def ink(page): + return page.locator('[data-ascii-canvas]').evaluate('(canvas) => canvas.toDataURL()') + + +def settled(page): + page.wait_for_timeout(250) + assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') + assert not page.evaluate('window.cspErrors'), page.evaluate('window.cspErrors') + + +def check_layout(page): + settled(page) + buttons = page.locator('[data-ascii-piece]') + boxes = [button.bounding_box() for button in buttons.all()] + assert max(box['y'] for box in boxes) - min(box['y'] for box in boxes) <= 1 + for box in boxes: + assert box['height'] >= 44 and box['width'] >= 44, box + assert page.locator('[data-ascii-piece][aria-pressed=true]').count() == 1 + canvas_color = page.locator('[data-ascii-canvas]').evaluate('el => getComputedStyle(el).color') + assert canvas_color == page.locator('body').evaluate('el => getComputedStyle(el).color') + assert ( + page.locator('.latest').bounding_box()['y'] < page.locator('.activity').bounding_box()['y'] + ) + + +def check_scenes(page, *, touch=False): + controls = page.locator('[data-ascii-controls]') + before = controls.bounding_box() + for name in ('flow', 'aurora', 'community', 'galaxy'): + button = page.locator(f'[data-ascii-piece={name}]') + if touch: + button.tap() + else: + button.focus() + page.keyboard.press('Enter') + expect(button).to_have_attribute('aria-pressed', 'true') + settled(page) + after = controls.bounding_box() + assert abs(before['y'] - after['y']) < 1, (before, after) + if name == 'community': + expect(page.locator('.signal-viewport img')).to_be_visible() + expect(page.locator('[data-ascii-canvas]')).to_be_hidden() + else: + expect(page.locator('[data-ascii-canvas]')).to_be_visible() + check_layout(page) + + +def main(): + OUTPUT.mkdir(parents=True, exist_ok=True) + # Export the actual rendered fixtures, not reconstructed mockups. + for path in ('/', '/guest', '/shared', '/long'): + (OUTPUT / f'{path[1:] or "home"}.html').write_bytes(render(path)) + (OUTPUT / 'art.svg').write_text(default()['svg']) + (OUTPUT / 'headers.json').write_text(json.dumps(HOME_BROWSER_HEADERS, indent=2) + '\n') + server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + origin = f'http://127.0.0.1:{server.server_port}' + results = [] + try: + with sync_playwright() as tool: + options = ( + {'executable_path': os.environ['MSG_BROWSER_EXECUTABLE']} + if os.environ.get('MSG_BROWSER_EXECUTABLE') + else {} + ) + browser = tool.chromium.launch(**options) + for width in (320, 360, 390, 768, 1440): + for theme in ('light', 'dark'): + context = browser.new_context( + viewport={'width': width, 'height': 960}, + color_scheme=theme, + locale='zh-CN', + reduced_motion='reduce', + has_touch=width < 768, + ) + page = context.new_page() + errors, external = [], [] + page.on('pageerror', lambda error, errors=errors: errors.append(str(error))) + page.on( + 'request', + lambda request, external=external: ( + external.append(request.url) + if not request.url.startswith(origin + '/') + else None + ), + ) + page.add_init_script( + "window.cspErrors=[];document.addEventListener('securitypolicyviolation'," + 'e=>cspErrors.push(e.violatedDirective));' + ) + page.goto(origin, wait_until='networkidle') + check_layout(page) + page.screenshot(path=str(OUTPUT / f'home-{width}-{theme}.png'), full_page=True) + check_scenes(page) + if width < 768: + check_scenes(page, touch=True) + # A still frame changes ink with the theme, but never resumes motion. + before = ink(page) + page.emulate_media(color_scheme='dark' if theme == 'light' else 'light') + settled(page) + assert ink(page) != before + before = ink(page) + page.wait_for_timeout(350) + assert ink(page) == before + page.evaluate("document.documentElement.dataset.theme='" + theme + "'") + settled(page) + assert ink(page) != before + # Menus, editor access and local drafts survive the new layout. + page.locator('.public-board-menu > summary').click() + page.locator('[data-edit]').click() + expect(page.locator('#public-board-editor')).to_be_visible() + page.locator('[name=text]').fill('Unsaved local fixture') + page.locator('[data-close]').click() + page.locator('.public-board-menu > summary').click() + page.keyboard.press('Escape') + expect(page.locator('.public-board-menu')).not_to_have_attribute('open', '') + page.locator('.public-board-menu > summary').click() + page.locator('[data-edit]').click() + expect(page.locator('[name=text]')).to_have_value('Unsaved local fixture') + page.locator('[name=text]').fill(default()['text']) + page.locator('[data-close]').click() + page.goto(origin + '/shared', wait_until='networkidle') + expect(page.locator('[data-ascii-piece=community]')).to_have_attribute( + 'aria-pressed', 'true' + ) + expect(page.locator('.signal-viewport img')).to_be_visible() + page.goto(origin + '/long', wait_until='networkidle') + settled(page) + expect(page.locator('[data-content]')).to_contain_text( + '' + ) + page.goto(origin + '/guest', wait_until='networkidle') + settled(page) + expect(page.locator('.account .primary')).to_have_attribute('href', '/login') + assert not errors and not external, (errors, external) + results.append({'width': width, 'theme': theme, 'passed': True}) + context.close() + context = browser.new_context(reduced_motion='no-preference') + page = context.new_page() + page.goto(origin, wait_until='networkidle') + before = ink(page) + page.wait_for_timeout(400) + assert ink(page) != before + page.locator('.public-board-menu > summary').click() + page.locator('[data-pause]').click() + page.wait_for_timeout(200) + before = ink(page) + page.wait_for_timeout(400) + assert ink(page) == before + page.evaluate("document.documentElement.dataset.theme='dark'") + page.wait_for_timeout(250) + assert ink(page) != before + before = ink(page) + page.wait_for_timeout(400) + assert ink(page) == before + context.close() + context = browser.new_context( + java_script_enabled=False, viewport={'width': 320, 'height': 960} + ) + page = context.new_page() + page.goto(origin + '/guest') + expect(page.locator('.signal-viewport img')).to_be_visible() + expect(page.locator('[data-ascii-controls]')).to_be_hidden() + expect(page.locator('.post-title a').first).to_have_text(POSTS[0]['title']) + assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') + context.close() + browser.close() + finally: + server.shutdown() + server.server_close() + (OUTPUT / 'results.json').write_text(json.dumps(results, indent=2) + '\n') + print(f'{len(results)} layout/theme cases, animation/pause and no-JavaScript fallback passed.') + + +if __name__ == '__main__': + main() diff --git a/src/msg/data/ascii-runtime.js b/src/msg/data/ascii-runtime.js index d3990096..d66065fa 100644 --- a/src/msg/data/ascii-runtime.js +++ b/src/msg/data/ascii-runtime.js @@ -1,5 +1,5 @@ /* ascii.rest by bas3line, MIT. Upstream fcb644403bfc25af0002e415d1006dd10b99ca34. See ascii-LICENSE.txt. */ -(()=>{var Tt=Object.defineProperty;var Lt=(t)=>t;function Ct(t,e){this[t]=Lt.bind(null,e)}var st=(t,e)=>{for(var o in e)Tt(t,o,{get:e[o],enumerable:!0,configurable:!0,set:Ct.bind(e,o)})};function yt(t,e,o={}){let i=typeof e==="function"?e:e.default,d=typeof e==="function"?{}:e.meta,{fps:w=d.fps??30,motion:E=!1,...r}={...d.options,...o},s=i(r),{cols:n=80,rows:h=24,palette:b,ground:x,cell:T=2}=d,f=t instanceof HTMLCanvasElement?t:null,y=b&&f?new Uint8Array(n*h):void 0,M=(g)=>g[0]==="#"?[1,3,5].map((S)=>parseInt(g.slice(S,S+2),16)):(g.match(/[\d.]+/g)||[]).map(Number),A=(g)=>{let S=M(g);return 0.2126*S[0]+0.7152*S[1]+0.0722*S[2]<128},R=()=>({paper:f&&x?!A(x):A(getComputedStyle(t).color),color:y}),u=0,a=()=>{t.textContent=s(u,R())},c;if(f){let g=(F)=>`${F}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`,S=f.getContext("2d"),D=document.createElement("canvas"),q=D.getContext("2d"),U=new Map,N=0,O=0,$=0,J=0,K=0,Q=0,nt=-1,ot="",rt=new Int32Array(0),at=new Int32Array(0),mt=new Int32Array(0),ht=new Int32Array(0),ft="",j=!0,dt=y&&new Uint8Array(n*h);f.style.display||="block",f.style.width||="100%",f.style.aspectRatio=`${n} / ${h*T}`;let Mt=()=>{nt=f.clientWidth,N=nt*(devicePixelRatio||1)/n,O=N*T,$=Math.ceil(N),J=Math.ceil(O),K=$+2,Q=J+2,f.width=Math.round(N*n),f.height=Math.round(O*h),D.width=K*32,D.height=Q*32,rt=Int32Array.from({length:n+1},(F,P)=>Math.round(P*N)),at=Int32Array.from({length:h+1},(F,P)=>Math.round(P*O)),mt=Int32Array.from({length:n},(F,P)=>Math.round(P*N+(N-$)/2)),ht=Int32Array.from({length:h},(F,P)=>Math.round(P*O+(O-J)/2)),U.clear(),j=!0},St=(F,P)=>{let H=F*256+P,k=U.get(H);if(k!==void 0)return k;if(U.size===1024)q.clearRect(0,0,D.width,D.height),U.clear();k=U.size;let B=k%32*K+1,z=Math.floor(k/32)*Q+1;return q.font=g(N/0.6),q.textAlign="center",q.textBaseline="middle",q.fillStyle=b?b[P]||b[0]:ot,q.fillText(String.fromCharCode(F),B+$/2,z+J/2),U.set(H,k),k};a=()=>{if(!b&&getComputedStyle(f).color!==ot)ot=getComputedStyle(f).color,U.clear(),q.clearRect(0,0,D.width,D.height),j=!0;let F=s(u,R());if(x)S.fillStyle=x;if(j)if(x)S.fillRect(0,0,f.width,f.height);else S.clearRect(0,0,f.width,f.height);for(let P=0,H=0,k=0;P{if(f.clientWidth!==nt)Mt(),a()}),c.observe(f)}if(a(),!w)return()=>c?.disconnect();let m=matchMedia("(prefers-reduced-motion: reduce)"),l=0,p=0,L=!1,v=(g)=>{l=requestAnimationFrame(v);let S=g-p;if(S<1000/w-2)return;p=g,u+=Math.min(S,100)/1000,a()},C=()=>{let g=L&&!document.hidden&&(E||!m.matches);if(g&&!l)p=performance.now(),l=requestAnimationFrame(v);else if(!g&&l)cancelAnimationFrame(l),l=0},I=new IntersectionObserver((g)=>{L=g[g.length-1].isIntersecting,C()});return I.observe(t),document.addEventListener("visibilitychange",C),m.addEventListener("change",C),()=>{I.disconnect(),c?.disconnect(),cancelAnimationFrame(l),l=0,document.removeEventListener("visibilitychange",C),m.removeEventListener("change",C)}}var ct={};st(ct,{meta:()=>bt,default:()=>gt});var bt={name:"galaxy",category:"space",note:"a tilted spiral galaxy turning, its inner stars the fastest",cols:64,rows:26,fps:20};var It=Math.PI*2,Pt=(t)=>()=>{t=t+1831565813|0;let e=Math.imul(t^t>>>15,1|t);return e=e+Math.imul(e^e>>>7,61|e)^e,((e^e>>>14)>>>0)/4294967296};function gt(){let{cols:t,rows:e}=bt,o=t/2,i=e/2,d=28,w=0.78,E=-0.14,r=Math.cos(-0.14),s=Math.sin(-0.14),n=1/Math.tan(0.5),h=0.1,b=Pt(11),x=Array.from({length:110},()=>({r:0.3+0.62*Math.sqrt(b()),a:b()*It,b:0.2+0.2*b()})),T=new Float32Array(t*e),f=[];for(let M=0;M<4;M++)for(let A=0;A<6;A++)for(let R=0;R<6;R++){let u=1+Math.floor((A+b())/6*(t-2)),a=1+Math.floor((M+b())/4*(e-2)),c=u-o,m=(a-i)*2,l=(c*r+m*s)/28,p=(-c*s+m*r)/21.84;if(l*l+p*p<1.4)continue;f.push([u+a*t,b()<0.2?"+":"."]);break}let y=(M,A,R)=>{let u=(M*r+A*s)/28,a=(-M*s+A*r)/21.84,c=Math.hypot(u,a);if(c>1.1)return 0;let m=Math.atan2(a,u),l=1.6*Math.exp(-((Math.hypot(u,a*0.78*1.4)/0.17)**2)),p=0.15*Math.exp(-c/0.3),L=2*(m-R-Math.log(c+0.000001)*n),v=(0.5+0.5*Math.cos(L))**3.5,C=(0.5+0.5*Math.cos(L-1.3))**8,I=Math.min(1,Math.max(0,(c-0.12)/0.16)),g=I*I*(3-2*I)*(1.3-0.8*c)*Math.exp(-((c/0.8)**3));return(l+p+v*g)*(1-0.75*C*g*Math.min(1,c/0.3))};return(M)=>{let A=-0.1*M;for(let a=0;a=0&&v=0&&CY,default:()=>xt});var Y={name:"flow field",category:"generative",note:"particle trails gathering into winding, merging streams",cols:64,rows:22,fps:20};function kt(t){return()=>{t=t+1831565813|0;let e=Math.imul(t^t>>>15,1|t);return e=e+Math.imul(e^e>>>7,61|e)^e,((e^e>>>14)>>>0)/4294967296}}function W(t,e,o){let i=Math.imul(t,668265261)^Math.imul(e,374761393)^Math.imul(o,2654435761);return i=Math.imul(i^i>>>15,2246822519),((i^i>>>13)>>>0)/4294967296}function Ft(t,e,o){let i=Math.floor(t),d=Math.floor(e),w=(h)=>h*h*h*(h*(h*6-15)+10),E=w(t-i),r=w(e-d),s=W(i,d,o)+(W(i+1,d,o)-W(i,d,o))*E,n=W(i,d+1,o)+(W(i+1,d+1,o)-W(i,d+1,o))*E;return s+(n-s)*r}function xt(){let{cols:t,rows:e}=Y,o=t,i=e*2,d=kt(24301),w=new Float32Array(t*e),E=new Uint8Array(t*e),r=[],s=new Float32Array(4),n=new Float32Array(4),h=(u,a)=>{for(let c=0;c<4;c++){let m=(l)=>{let p=((c+0.5)/4-0.5)*i+12*(2*Ft(l*0.085+c*7.3,a+c*3.1,c)-1);return i/2+(i/2-3)*Math.tanh(p/(i/2-3))};s[c]=m(u),n[c]=m(u+0.5)-m(u-0.5)}},b=(u,a,c,m)=>{h(u,m);let l=0,p=0.000000001,L=0,v=1e9;for(let I=0;I<4;I++){let g=s[I]+c-a,S=Math.exp(-((g/7)**2));if(l+=S*(n[I]+0.22*g),p+=S,Math.abs(g)0.0001?l/p:L;return Math.max(-1.2,Math.min(1.2,Math.atan(C)))},x=Math.atan(2),T=(u)=>{let a=Math.abs(u);if(a(Math.PI/2+x)/2)return"|";return u>0?"\\":"/"},f=(u,a,c)=>{let m=Math.floor(u),l=Math.floor(a/2);if(m<0||m>=t||l<0||l>=e)return;let p=l*t+m;w[p]=1,E[p]=T(c).charCodeAt(0)},y=0,M=0,A=(u)=>{y+=u;let a=Math.exp(-u/1.6);for(let m=0;m=0;m--){let l=r[m],p=b(l.x,l.y,l.o,c),L=Math.cos(p)*9*u,v=Math.sin(p)*9*u,C=Math.max(1,Math.ceil(Math.hypot(L,v*0.5)*1.5));for(let I=1;I<=C;I++)f(l.x+L*I/C,l.y+v*I/C,p);if(l.x+=L,l.y+=v,l.x>=o||l.y<0||l.y>=i)r.splice(m,1)}};for(let u=0;u<9*Y.fps;u++)A(1/Y.fps);let R=0;return(u)=>{let a=Math.min(0.1,Math.max(0,u-R));if(R=u,a>0)A(a);let c=[];for(let m=0;m0.5?String.fromCharCode(E[L]):v>0.27?"·":v>0.14?".":" "}c.push(l)}return c.join(` -`)}}var ut={};st(ut,{meta:()=>At,default:()=>wt});var At={name:"aurora",category:"nature",note:"an aurora curtain rippling over a treeline under stars",cols:64,rows:20,fps:20};var Et=Math.PI*2;var Ht=(t)=>()=>{t=t+1831565813|0;let e=Math.imul(t^t>>>15,1|t);return e=e+Math.imul(e^e>>>7,61|e)^e,((e^e>>>14)>>>0)/4294967296},Z=(t,e)=>{let o=Math.imul(t,668265261)^Math.imul(e,374761393);return o=Math.imul(o^o>>>15,2246822507),o=Math.imul(o^o>>>13,3266489909),((o^o>>>16)>>>0)/4294967296},tt=(t)=>t*t*(3-2*t);function lt(t,e){let o=Math.floor(t),i=Math.floor(e),d=tt(t-o),w=tt(e-i),E=Z(o,i),r=Z(o+1,i),s=Z(o,i+1),n=Z(o+1,i+1);return E+(r-E)*d+(s-E)*w+(E-r-s+n)*d*w}function wt(){let{cols:t,rows:e}=At,o=Ht(5),i=Array(t*e).fill(null);for(let r=0;r0.3?"█":"▄";for(let s=19;s>1);for(let x=r-b+1;x<=r+b;x++){if(x<0||x>=t)continue;let T=x===r-b+1,f=x===r+b;i[x+h*t]=T?n&1?"▟":"▗":f?n&1?"▙":"▖":"█"}}for(let n=r-(s>>1);n<=r+1+(s>>1);n++)if(n>=0&&n>1)+1+Math.floor(o()*3)}let d=[];while(d.length<40){let r=Math.floor(o()*t),s=Math.floor(o()*o()*14);d.push({k:r+s*t,bright:o()<0.2,rate:0.3+o()*1.1,ph:o()*Et})}let w=new Float32Array(t*e),E=Array(t*e);return(r)=>{w.fill(0);for(let n=0;n0?0.6:0.35))**2)),u=Math.max(R,A>=0?(0.3+0.7*f)*Math.max(0,1-A/y)**1.4:Math.exp(A/1.5)*(0.4+0.4*f));w[n+M*t]=T*u}}for(let n=0;n0?" .:!|I"[Math.min(5,1+Math.floor(h*5))]:" ")}for(let n of d){if(E[n.k]!==" ")continue;let h=Math.sin(r*n.rate*Et+n.ph);E[n.k]=n.bright?h>0.3?"+":"*":h>-0.6?".":" "}let s=[];for(let n=0;n(R,u)=>(b+=Math.max(0,R-M),M=R,x(b,u))};try{n=yt(e,A,{fps:w?.getAttribute("aria-pressed")==="true"?0:16})}catch{f("community")}}function f(y){if(y!=="community"&&!(y in et))return;if(s=y,b=0,s!=="community")x=et[s].default();if(e.hidden=s==="community",i)i.hidden=s!=="community";if(t.dataset.asciiScene=s,t.querySelectorAll("[data-ascii-piece]").forEach((M)=>{M.setAttribute("aria-pressed",String(M.dataset.asciiPiece===s))}),r)r.hidden=!1;if(r)r.textContent=s==="community"?"Shared artwork / 公共作品":"Character study / 字符习作 · ascii.rest";T()}if(d?.addEventListener("click",(y)=>{let M=y.target.closest("[data-ascii-piece]");if(M)f(M.dataset.asciiPiece)}),!o)E?.addEventListener("click",()=>{t.hidden=!t.hidden,E.setAttribute("aria-expanded",String(!t.hidden)),T()}),t.querySelector("[data-ascii-close]")?.addEventListener("click",()=>{t.hidden=!0,E?.setAttribute("aria-expanded","false"),T(),E?.focus()});if(o?.addEventListener("msg:board-updated",(y)=>{if(y.detail.svgChanged)f("community")}),w)new MutationObserver(T).observe(w,{attributes:!0,attributeFilter:["aria-pressed"]});if(t.dataset.asciiReady="true",d)d.hidden=!1;f(s),vt.add(()=>{h=!0,n?.()}),addEventListener("pageshow",()=>{h=!1,T()})}function Rt(){document.querySelectorAll("[data-ascii-surface]").forEach(Dt)}if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",Rt,{once:!0});else Rt();addEventListener("pagehide",()=>vt.forEach((t)=>t()));})(); +(()=>{var Lt=Object.defineProperty;var Ct=(t)=>t;function It(t,e){this[t]=Ct.bind(null,e)}var ct=(t,e)=>{for(var o in e)Lt(t,o,{get:e[o],enumerable:!0,configurable:!0,set:It.bind(e,o)})};function yt(t,e,o={}){let i=typeof e==="function"?e:e.default,f=typeof e==="function"?{}:e.meta,{fps:w=f.fps??30,motion:E=!1,...r}={...f.options,...o},s=i(r),{cols:n=80,rows:h=24,palette:y,ground:x,cell:v=2}=f,d=t instanceof HTMLCanvasElement?t:null,b=y&&d?new Uint8Array(n*h):void 0,M=(g)=>g[0]==="#"?[1,3,5].map((T)=>parseInt(g.slice(T,T+2),16)):(g.match(/[\d.]+/g)||[]).map(Number),A=(g)=>{let T=M(g);return 0.2126*T[0]+0.7152*T[1]+0.0722*T[2]<128},R=()=>({paper:d&&x?!A(x):A(getComputedStyle(t).color),color:b}),u=0,a=()=>{t.textContent=s(u,R())},c;if(d){let g=(F)=>`${F}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`,T=d.getContext("2d"),D=document.createElement("canvas"),q=D.getContext("2d"),O=new Map,N=0,U=0,J=0,K=0,Q=0,V=0,ot=-1,rt="",at=new Int32Array(0),st=new Int32Array(0),ht=new Int32Array(0),dt=new Int32Array(0),ft="",j=!0,Mt=b&&new Uint8Array(n*h);d.style.display||="block",d.style.width||="100%",d.style.aspectRatio=`${n} / ${h*v}`;let pt=()=>{ot=d.clientWidth,N=ot*(devicePixelRatio||1)/n,U=N*v,J=Math.ceil(N),K=Math.ceil(U),Q=J+2,V=K+2,d.width=Math.round(N*n),d.height=Math.round(U*h),D.width=Q*32,D.height=V*32,at=Int32Array.from({length:n+1},(F,P)=>Math.round(P*N)),st=Int32Array.from({length:h+1},(F,P)=>Math.round(P*U)),ht=Int32Array.from({length:n},(F,P)=>Math.round(P*N+(N-J)/2)),dt=Int32Array.from({length:h},(F,P)=>Math.round(P*U+(U-K)/2)),O.clear(),j=!0},Tt=(F,P)=>{let H=F*256+P,k=O.get(H);if(k!==void 0)return k;if(O.size===1024)q.clearRect(0,0,D.width,D.height),O.clear();k=O.size;let B=k%32*Q+1,z=Math.floor(k/32)*V+1;return q.font=g(N/0.6),q.textAlign="center",q.textBaseline="middle",q.fillStyle=y?y[P]||y[0]:rt,q.fillText(String.fromCharCode(F),B+J/2,z+K/2),O.set(H,k),k};a=()=>{if(!y&&getComputedStyle(d).color!==rt)rt=getComputedStyle(d).color,O.clear(),q.clearRect(0,0,D.width,D.height),j=!0;let F=s(u,R());if(x)T.fillStyle=x;if(j)if(x)T.fillRect(0,0,d.width,d.height);else T.clearRect(0,0,d.width,d.height);for(let P=0,H=0,k=0;P{if(d.clientWidth!==ot)pt(),a()}),c.observe(d)}if(a(),!w)return()=>c?.disconnect();let m=matchMedia("(prefers-reduced-motion: reduce)"),l=0,p=0,L=!1,S=(g)=>{l=requestAnimationFrame(S);let T=g-p;if(T<1000/w-2)return;p=g,u+=Math.min(T,100)/1000,a()},C=()=>{let g=L&&!document.hidden&&(E||!m.matches);if(g&&!l)p=performance.now(),l=requestAnimationFrame(S);else if(!g&&l)cancelAnimationFrame(l),l=0},I=new IntersectionObserver((g)=>{L=g[g.length-1].isIntersecting,C()});return I.observe(t),document.addEventListener("visibilitychange",C),m.addEventListener("change",C),()=>{I.disconnect(),c?.disconnect(),cancelAnimationFrame(l),l=0,document.removeEventListener("visibilitychange",C),m.removeEventListener("change",C)}}var it={};ct(it,{default:()=>xt,meta:()=>gt});var gt={name:"galaxy",category:"space",note:"a tilted spiral galaxy turning, its inner stars the fastest",cols:64,rows:26,fps:20};var Pt=Math.PI*2,kt=(t)=>()=>{t=t+1831565813|0;let e=Math.imul(t^t>>>15,1|t);return e=e+Math.imul(e^e>>>7,61|e)^e,((e^e>>>14)>>>0)/4294967296};function xt(){let{cols:t,rows:e}=gt,o=t/2,i=e/2,f=28,w=0.78,E=-0.14,r=Math.cos(-0.14),s=Math.sin(-0.14),n=1/Math.tan(0.5),h=0.1,y=kt(11),x=Array.from({length:110},()=>({r:0.3+0.62*Math.sqrt(y()),a:y()*Pt,b:0.2+0.2*y()})),v=new Float32Array(t*e),d=[];for(let M=0;M<4;M++)for(let A=0;A<6;A++)for(let R=0;R<6;R++){let u=1+Math.floor((A+y())/6*(t-2)),a=1+Math.floor((M+y())/4*(e-2)),c=u-o,m=(a-i)*2,l=(c*r+m*s)/28,p=(-c*s+m*r)/21.84;if(l*l+p*p<1.4)continue;d.push([u+a*t,y()<0.2?"+":"."]);break}let b=(M,A,R)=>{let u=(M*r+A*s)/28,a=(-M*s+A*r)/21.84,c=Math.hypot(u,a);if(c>1.1)return 0;let m=Math.atan2(a,u),l=1.6*Math.exp(-((Math.hypot(u,a*0.78*1.4)/0.17)**2)),p=0.15*Math.exp(-c/0.3),L=2*(m-R-Math.log(c+0.000001)*n),S=(0.5+0.5*Math.cos(L))**3.5,C=(0.5+0.5*Math.cos(L-1.3))**8,I=Math.min(1,Math.max(0,(c-0.12)/0.16)),g=I*I*(3-2*I)*(1.3-0.8*c)*Math.exp(-((c/0.8)**3));return(l+p+S*g)*(1-0.75*C*g*Math.min(1,c/0.3))};return(M)=>{let A=-0.1*M;for(let a=0;a=0&&S=0&&CEt,meta:()=>Z});var Z={name:"flow field",category:"generative",note:"particle trails gathering into winding, merging streams",cols:64,rows:22,fps:20};function Ft(t){return()=>{t=t+1831565813|0;let e=Math.imul(t^t>>>15,1|t);return e=e+Math.imul(e^e>>>7,61|e)^e,((e^e>>>14)>>>0)/4294967296}}function W(t,e,o){let i=Math.imul(t,668265261)^Math.imul(e,374761393)^Math.imul(o,2654435761);return i=Math.imul(i^i>>>15,2246822519),((i^i>>>13)>>>0)/4294967296}function Ht(t,e,o){let i=Math.floor(t),f=Math.floor(e),w=(h)=>h*h*h*(h*(h*6-15)+10),E=w(t-i),r=w(e-f),s=W(i,f,o)+(W(i+1,f,o)-W(i,f,o))*E,n=W(i,f+1,o)+(W(i+1,f+1,o)-W(i,f+1,o))*E;return s+(n-s)*r}function Et(){let{cols:t,rows:e}=Z,o=t,i=e*2,f=Ft(24301),w=new Float32Array(t*e),E=new Uint8Array(t*e),r=[],s=new Float32Array(4),n=new Float32Array(4),h=(u,a)=>{for(let c=0;c<4;c++){let m=(l)=>{let p=((c+0.5)/4-0.5)*i+12*(2*Ht(l*0.085+c*7.3,a+c*3.1,c)-1);return i/2+(i/2-3)*Math.tanh(p/(i/2-3))};s[c]=m(u),n[c]=m(u+0.5)-m(u-0.5)}},y=(u,a,c,m)=>{h(u,m);let l=0,p=0.000000001,L=0,S=1e9;for(let I=0;I<4;I++){let g=s[I]+c-a,T=Math.exp(-((g/7)**2));if(l+=T*(n[I]+0.22*g),p+=T,Math.abs(g)0.0001?l/p:L;return Math.max(-1.2,Math.min(1.2,Math.atan(C)))},x=Math.atan(2),v=(u)=>{let a=Math.abs(u);if(a(Math.PI/2+x)/2)return"|";return u>0?"\\":"/"},d=(u,a,c)=>{let m=Math.floor(u),l=Math.floor(a/2);if(m<0||m>=t||l<0||l>=e)return;let p=l*t+m;w[p]=1,E[p]=v(c).charCodeAt(0)},b=0,M=0,A=(u)=>{b+=u;let a=Math.exp(-u/1.6);for(let m=0;m=0;m--){let l=r[m],p=y(l.x,l.y,l.o,c),L=Math.cos(p)*9*u,S=Math.sin(p)*9*u,C=Math.max(1,Math.ceil(Math.hypot(L,S*0.5)*1.5));for(let I=1;I<=C;I++)d(l.x+L*I/C,l.y+S*I/C,p);if(l.x+=L,l.y+=S,l.x>=o||l.y<0||l.y>=i)r.splice(m,1)}};for(let u=0;u<9*Z.fps;u++)A(1/Z.fps);let R=0;return(u)=>{let a=Math.min(0.1,Math.max(0,u-R));if(R=u,a>0)A(a);let c=[];for(let m=0;m0.5?String.fromCharCode(E[L]):S>0.27?"·":S>0.14?".":" "}c.push(l)}return c.join(` +`)}}var mt={};ct(mt,{default:()=>vt,meta:()=>wt});var wt={name:"aurora",category:"nature",note:"an aurora curtain rippling over a treeline under stars",cols:64,rows:20,fps:20};var At=Math.PI*2;var Dt=(t)=>()=>{t=t+1831565813|0;let e=Math.imul(t^t>>>15,1|t);return e=e+Math.imul(e^e>>>7,61|e)^e,((e^e>>>14)>>>0)/4294967296},tt=(t,e)=>{let o=Math.imul(t,668265261)^Math.imul(e,374761393);return o=Math.imul(o^o>>>15,2246822507),o=Math.imul(o^o>>>13,3266489909),((o^o>>>16)>>>0)/4294967296},et=(t)=>t*t*(3-2*t);function ut(t,e){let o=Math.floor(t),i=Math.floor(e),f=et(t-o),w=et(e-i),E=tt(o,i),r=tt(o+1,i),s=tt(o,i+1),n=tt(o+1,i+1);return E+(r-E)*f+(s-E)*w+(E-r-s+n)*f*w}function vt(){let{cols:t,rows:e}=wt,o=Dt(5),i=Array(t*e).fill(null);for(let r=0;r0.3?"█":"▄";for(let s=19;s>1);for(let x=r-y+1;x<=r+y;x++){if(x<0||x>=t)continue;let v=x===r-y+1,d=x===r+y;i[x+h*t]=v?n&1?"▟":"▗":d?n&1?"▙":"▖":"█"}}for(let n=r-(s>>1);n<=r+1+(s>>1);n++)if(n>=0&&n>1)+1+Math.floor(o()*3)}let f=[];while(f.length<40){let r=Math.floor(o()*t),s=Math.floor(o()*o()*14);f.push({k:r+s*t,bright:o()<0.2,rate:0.3+o()*1.1,ph:o()*At})}let w=new Float32Array(t*e),E=Array(t*e);return(r)=>{w.fill(0);for(let n=0;n0?0.6:0.35))**2)),u=Math.max(R,A>=0?(0.3+0.7*d)*Math.max(0,1-A/b)**1.4:Math.exp(A/1.5)*(0.4+0.4*d));w[n+M*t]=v*u}}for(let n=0;n0?" .:!|I"[Math.min(5,1+Math.floor(h*5))]:" ")}for(let n of f){if(E[n.k]!==" ")continue;let h=Math.sin(r*n.rate*At+n.ph);E[n.k]=n.bright?h>0.3?"+":"*":h>-0.6?".":" "}let s=[];for(let n=0;n(R,u)=>(y+=Math.max(0,R-M),M=R,x(y,u))};try{n=yt(e,A,{fps:w?.getAttribute("aria-pressed")==="true"?0:16})}catch{d("community")}}function d(b){if(b!=="community"&&!(b in nt))return;if(s=b,y=0,s!=="community")x=nt[s].default();if(e.hidden=s==="community",i)i.hidden=s!=="community";if(t.dataset.asciiScene=s,t.querySelectorAll("[data-ascii-piece]").forEach((M)=>{M.setAttribute("aria-pressed",String(M.dataset.asciiPiece===s))}),r)r.hidden=!1;if(r)r.textContent=s==="community"?"Shared artwork / 公共作品":"Character study / 字符习作 · ascii.rest";v()}if(f?.addEventListener("click",(b)=>{let M=b.target.closest("[data-ascii-piece]");if(M)d(M.dataset.asciiPiece)}),!o)E?.addEventListener("click",()=>{t.hidden=!t.hidden,E.setAttribute("aria-expanded",String(!t.hidden)),v()}),t.querySelector("[data-ascii-close]")?.addEventListener("click",()=>{t.hidden=!0,E?.setAttribute("aria-expanded","false"),v(),E?.focus()});if(o?.addEventListener("msg:board-updated",(b)=>{if(b.detail.svgChanged)d("community")}),w)new MutationObserver(v).observe(w,{attributes:!0,attributeFilter:["aria-pressed"]});if(new MutationObserver(v).observe(document.documentElement,{attributes:!0,attributeFilter:["data-theme"]}),matchMedia("(prefers-color-scheme: dark)").addEventListener("change",v),t.dataset.asciiReady="true",f)f.hidden=!1;d(s),St.add(()=>{h=!0,n?.()}),addEventListener("pageshow",()=>{h=!1,v()})}function Rt(){document.querySelectorAll("[data-ascii-surface]").forEach(Nt)}if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",Rt,{once:!0});else Rt();addEventListener("pagehide",()=>St.forEach((t)=>t()));})(); diff --git a/src/msg/transports/ascii_art.py b/src/msg/transports/ascii_art.py index e8657954..d48c6396 100644 --- a/src/msg/transports/ascii_art.py +++ b/src/msg/transports/ascii_art.py @@ -11,43 +11,117 @@ CONTROLS = ( '' + + ''.join( + f'' + for piece, english, chinese in [ + ('galaxy', 'Galaxy', '星系'), + ('flow', 'Flow', '信号流'), + ('aurora', 'Aurora', '极光'), + ('community', 'Shared SVG', '公共作品'), + ] + ) + + '' ) CSS = """ -/* The character field is a stage, never a raster image behind reading text. */ -.public-board{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.15fr);column-gap:48px;padding:36px 0 54px;align-items:center} -.public-board .signal-stage{grid-column:2;grid-row:1 / span 2;min-width:0;position:relative;border-radius:16px;overflow:hidden;background:#080d12;color:#bedcdd} -.public-board .signal-stage[data-ascii-scene=community]{background:transparent;color:var(--fg)} -.public-board .signal-stage img{margin:0;aspect-ratio:64/48;object-fit:contain} -.public-board .signal-stage canvas{width:100%;margin:0;display:block} -.public-board .signal-stage canvas[hidden],.public-board .signal-stage img[hidden]{display:none} -.public-board .signal-stage[data-ascii-scene=flow]{color:#9bcdc3} -.public-board .signal-stage[data-ascii-scene=aurora]{color:#b7cba5} -.public-board .public-board-text{grid-column:1;grid-row:1;align-self:end;font-size:clamp(28px,3.5vw,46px);line-height:1.22;letter-spacing:-.03em;max-width:22ch;margin:0 0 24px;text-wrap:balance} -.public-board .signal-links{grid-column:1;grid-row:2;align-self:start;display:flex;align-items:center;gap:24px;flex-wrap:wrap;margin:0;padding-bottom:20px} -.public-board .signal-links a{min-height:44px;display:inline-flex;align-items:center;text-underline-offset:5px;font-size:14px} -.public-board .signal-links a:first-child{font-weight:600;color:var(--fg)} -.public-board .signal-links svg{width:16px;height:16px;margin-inline-start:8px;stroke:currentColor;fill:none;stroke-width:1.5} -.public-board .ascii-controls{display:flex;gap:0;border-top:1px solid #304047;flex-wrap:wrap;padding:4px 12px;background:#080d12;color:#bedcdd} -.public-board .ascii-controls[hidden]{display:none} -.public-board .ascii-controls button{flex:1 1 auto;border:0;background:transparent;color:inherit;padding:8px 10px;font-size:11px;border-radius:4px;min-height:44px;white-space:nowrap} -.public-board .ascii-controls button[aria-pressed=true]{color:#fff;text-decoration:underline;text-underline-offset:7px;text-decoration-thickness:2px} -.public-board .ascii-controls button:hover{background:#1c292f} -.public-board .ascii-controls button:focus-visible{outline:2px solid #bedcdd;outline-offset:-3px} -.public-board .signal-caption{font:10px/1.6 var(--mono);color:#9eb5bb;background:#080d12;margin:0;padding:0 22px 12px} -.public-board [data-success],.public-board form{grid-column:1 / -1} -.public-board-menu{top:0!important} -@media(max-width:760px){ - .public-board{grid-template-columns:1fr;gap:0;padding-top:26px;padding-bottom:32px} - .public-board .public-board-text{grid-row:1;font-size:clamp(28px,6.7vw,40px);max-width:27ch;margin-bottom:18px} - .public-board .signal-links{grid-row:2;padding-bottom:24px} - .public-board .signal-stage{grid-column:1;grid-row:3;border-radius:12px} - .public-board .ascii-controls{padding:4px 6px}.public-board .ascii-controls button{font-size:10.5px;padding:8px 7px} -} -@media print{.signal-stage{display:none}.public-board{display:block}.signal-links{display:none!important}} +/* An open, monochrome character stage. Text and controls never cover the art. */ +.public-board { + display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.12fr); + gap: 0 64px; align-items: center; padding: 24px 0 52px; +} +.signal-copy { min-width: 0; } +.signal-meta { + display: flex; align-items: center; justify-content: space-between; + gap: 16px; min-height: 44px; margin-bottom: 18px; +} +.signal-mark { color: var(--muted); font: 12px/1 var(--mono); letter-spacing: .08em; } +.public-board .public-board-menu { position: relative; inset: auto; flex: none; } +.public-board .public-board-menu > summary { border-radius: 4px; } +.public-board .public-board-text { + max-width: 18ch; margin: 0; font: 550 clamp(32px, 3.8vw, 48px)/1.28 var(--sans); + letter-spacing: -.045em; text-wrap: balance; +} +.public-board .signal-links { + display: flex; align-items: center; gap: 8px 24px; flex-wrap: wrap; margin: 26px 0 0; +} +.public-board .signal-links a { + display: inline-flex; align-items: center; min-height: 44px; padding: 0; + border-radius: 0; font: 12px/1.5 var(--mono); color: var(--muted); + text-decoration: none; text-underline-offset: 6px; +} +.public-board .signal-links a:first-child { color: var(--fg); } +.public-board .signal-links a:hover { background: none; text-decoration: underline; } +.public-board .signal-links svg { + width: 16px; height: 16px; margin-inline-start: 12px; + stroke: currentColor; fill: none; stroke-width: 1.5; +} +.public-board .signal-stage { min-width: 0; color: var(--fg); } +/* Reserve the tallest scene's space; switching scenes must not move the page. */ +.signal-viewport { + position: relative; display: grid; place-items: center; min-width: 0; + aspect-ratio: 16 / 13; isolation: isolate; +} +.signal-viewport::before, .signal-viewport::after { + content: '+'; position: absolute; color: var(--line-strong); + font: 14px/1 var(--mono); pointer-events: none; +} +.signal-viewport::before { inset: 0 auto auto 0; } +.signal-viewport::after { inset: auto 0 0 auto; } +.public-board .signal-viewport canvas, .public-board .signal-viewport img { + grid-area: 1 / 1; display: block; width: 100%; min-width: 0; height: auto; margin: 0; +} +.public-board .signal-viewport img { aspect-ratio: 16 / 5; object-fit: contain; } +/* The vendor sets inline display:block; the hidden state must still win. */ +.public-board .signal-viewport :is(canvas, img)[hidden] { display: none !important; } +.public-board .ascii-controls { + display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 4px; + margin-top: 16px; padding: 0; +} +.public-board .ascii-controls[hidden] { display: none; } +.public-board .ascii-controls button { + position: relative; display: grid; align-content: center; justify-items: center; gap: 3px; + min-width: 0; min-height: 56px; padding: 9px 2px; border: 0; border-radius: 2px; + background: transparent; color: var(--muted); font: 11px/1.4 var(--mono); +} +.ascii-piece-name { white-space: nowrap; } +.ascii-piece-label { font: 11px/1.4 var(--sans); } +.public-board .ascii-controls button[aria-pressed=true] { color: var(--fg); background: var(--panel); } +.public-board .ascii-controls button[aria-pressed=true]::before, +.public-board .ascii-controls button[aria-pressed=true]::after { + position: absolute; top: 50%; transform: translateY(-50%); color: var(--muted); font: 12px/1 var(--mono); +} +.public-board .ascii-controls button[aria-pressed=true]::before { content: '['; left: 4px; } +.public-board .ascii-controls button[aria-pressed=true]::after { content: ']'; right: 4px; } +.public-board .ascii-controls button:hover { color: var(--fg); background: var(--panel); } +.public-board .ascii-controls button:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; } +.public-board .signal-caption { + margin: 14px 0 0; padding: 0; color: var(--muted); text-align: center; + font: 10px/1.7 var(--mono); letter-spacing: .01em; +} +.public-board [data-success], .public-board form { grid-column: 1 / -1; } +@media (max-width: 860px) { + .public-board { gap: 0 32px; } + .public-board .signal-links { gap: 4px 18px; } +} +@media (max-width: 760px) { + .public-board { grid-template-columns: minmax(0, 1fr); gap: 24px; padding: 4px 0 28px; } + .signal-meta { margin-bottom: 12px; } + .public-board .public-board-text { max-width: 19ch; font-size: clamp(32px, 8.5vw, 48px); } + .public-board .signal-links { margin-top: 20px; gap: 8px 24px; } + .public-board .signal-stage { width: min(100%, 480px); margin-inline: auto; } + .public-board .ascii-controls { margin-top: 12px; } +} +@media (max-width: 360px) { + .public-board .ascii-controls { gap: 2px; } + .public-board .ascii-controls button[aria-pressed=true]::before, + .public-board .ascii-controls button[aria-pressed=true]::after { content: none; } +} +@media print { + .public-board .signal-stage, .public-board .signal-links, .public-board .signal-meta { display: none; } + .public-board { display: block; } +} """ diff --git a/src/msg/transports/browser_style.py b/src/msg/transports/browser_style.py index d623c4cc..91b5de09 100644 --- a/src/msg/transports/browser_style.py +++ b/src/msg/transports/browser_style.py @@ -259,15 +259,25 @@ .page-home #content > .public-board { margin-block: 4px 8px; } .account { display: flex; align-items: center; justify-content: space-between; gap: 16px 32px; - margin-block: 12px 56px; padding: 22px 26px; background: var(--panel); border-radius: var(--radius-panel); + margin-block: 12px 24px; padding: 24px 0; border-top: 1px solid var(--line); + background: none; border-radius: 0; } .account:has(> .primary) { flex-direction: row-reverse; } -.account h2 { font-size: 15px; margin: 0 0 2px; } +.account > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 24px; min-width: 0; } +.account h2 { color: var(--muted); font: 12px/1.5 var(--mono); margin: 0; } +.account > div > p { grid-column: 1; } .account p { margin: 0; color: var(--muted); font-size: 14.5px; line-height: 1.6; max-width: 62ch; } -.account p a { color: var(--fg); font: 600 15px/1.6 var(--mono); text-decoration: none; } -.account nav a { color: var(--fg); } -.account nav a:hover { background: var(--bg); } -.account-groups summary { color: var(--muted); font-size: 13px; } +.account p a { color: var(--fg); font: 500 18px/1.6 var(--mono); text-decoration: none; overflow-wrap: anywhere; } +.account nav { gap: 0 18px; } +.account nav a { padding-inline: 0; color: var(--muted); font: 12px/1.5 var(--mono); border-radius: 0; } +.account nav a:hover { color: var(--fg); background: none; text-decoration: underline; text-underline-offset: 5px; } +.account-groups { grid-column: 2; grid-row: 1 / span 2; align-self: center; max-width: 180px; } +.account-groups summary { display: flex; align-items: center; min-height: 44px; color: var(--muted); font-size: 12px; } +.account-groups summary::before { content: '+'; padding-inline-end: 8px; font-family: var(--mono); } +.account-groups[open] summary::before { content: '-'; } +.account-groups summary::-webkit-details-marker { display: none; } +.account-groups ul { list-style: none; margin: 0; padding: 0; font: 12px/1.6 var(--mono); } +.account-groups li a { display: inline-flex; align-items: center; min-height: 44px; } .page-home #content > section:is(.activity, .latest, .channels) { display: grid; grid-template-columns: 200px minmax(0, 1fr); column-gap: 56px; margin-block: 0; padding-block: 40px 48px; @@ -735,7 +745,7 @@ .prose h1 { font-size: 34px; } .lead { font-size: 17px; } section { margin-block: 40px; } - .account { align-items: flex-start; flex-direction: column; padding: 20px; } + .account { align-items: stretch; flex-direction: column; gap: 8px; padding: 20px 0; } .account:has(> .primary) { flex-direction: column-reverse; } .stats > div { padding-inline: 14px; } .stats strong { font-size: 28px; } diff --git a/src/msg/transports/public_board.py b/src/msg/transports/public_board.py index ffd61dc1..ca397909 100644 --- a/src/msg/transports/public_board.py +++ b/src/msg/transports/public_board.py @@ -144,19 +144,22 @@ def html(value=None, account=None, csrf=''): f'data-ascii-default="{str(value["svg"] == default()["svg"]).lower()}" ' f'data-generation="{value["generation"]}" data-signed-in="{str(bool(account)).lower()}" ' f'data-csrf="{escape(csrf, quote=True)}" aria-label="公共栏 / Shared board">' + '
' + '' '
⋯' '
' '' '
' + '
' + f'

{escape(value["text"])}

' + '
' '
' + '
' '' f'用户共同编辑的 SVG 动图 / Community SVG animation' - + CONTROLS - + '
' - + f'

{escape(value["text"])}

' - '' + '
' + CONTROLS + '' '' '