- billboard/0005 adds Line.admin_solicited (BooleanField default False); RunPython backfills existing note_unlock Lines to True. Note.grant_if_new sets admin_solicited=True on its system prose.
- billboard.models post_save signal: any Line saved on a Post.kind=NOTE_UNLOCK without admin_solicited=True is deleted (defense-in-depth alongside the view guard).
- billboard.views.view_post hard-rejects POST on NOTE_UNLOCK kind (HTTP 403) — clean view-level contract; the post_save listener is the safety net for ORM/API paths that bypass it.
- templates/apps/billboard/post.html: NOTE_UNLOCK branch renders the input as readonly w. 'No response needed at this time' placeholder + no method/action; user_post branch keeps the regular composer. Buddy panel include guarded behind `{% if post.kind != 'note_unlock' %}` — friend invites don't apply to admin threads.
- SCSS: .post-line-form input.form-control[readonly]:focus uses --secUser glow (cooler than the regular --terUser composer focus).
- share_post: drop the iso-timestamp suffix on Line.text (just 'Shared with {email}'); author = request.user (anon legacy fallback to adman so AnonymousUser doesn't break the FK); re-share of an already-in-shared_with recipient is a silent no-op (no second Line, brief: null in JSON response). Buddy panel JS now reads data-sharer-name from server-rendered display_name so the optimistic _appendLine matches the post-refresh state.
- new ITs: test_admin_posts (PostRejectsAdminWritesTest, UnsolicitedLineListenerTest, NoteGrantSetsAdminSolicitedTest) — 7 tests; share_post tests rewritten for the new contract (drop ts, author=sharer, silent re-share dedup) — 12 tests; new FT test_admin_post_readonly w. AdminPostInputReadonlyTest + AdminPostHasNoBuddyBtnTest + UserPostInputUnaffectedTest — 4 tests. 827 ITs + 18 buddy/sharing FTs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
322 lines
8.7 KiB
SCSS
322 lines
8.7 KiB
SCSS
// ── My Posts applet ────────────────────────────────────────────────────────
|
|
|
|
#id_applet_my_posts {
|
|
display: flex;
|
|
flex-direction: column;
|
|
|
|
.my-posts-container {
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
-webkit-overflow-scrolling: touch;
|
|
scrollbar-width: none;
|
|
&::-webkit-scrollbar { display: none; }
|
|
mask-origin: padding-box;
|
|
mask-clip: padding-box;
|
|
mask-image: linear-gradient(
|
|
to bottom,
|
|
transparent 0%,
|
|
black 5%,
|
|
black 85%,
|
|
transparent 100%
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Shared aperture fill for both billboard pages ──────────────────────────
|
|
|
|
%billboard-page-base {
|
|
flex: 1;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
position: relative;
|
|
}
|
|
|
|
html:has(body.page-billboard),
|
|
html:has(body.page-billscroll),
|
|
html:has(body.page-billpost) {
|
|
overflow: hidden;
|
|
}
|
|
|
|
body.page-billboard,
|
|
body.page-billscroll,
|
|
body.page-billpost {
|
|
overflow: hidden;
|
|
|
|
.container {
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
.row {
|
|
flex-shrink: 0;
|
|
margin-bottom: -1rem;
|
|
}
|
|
}
|
|
|
|
// ── Billboard page (three-applet grid) ─────────────────────────────────────
|
|
|
|
.billboard-page {
|
|
@extend %billboard-page-base;
|
|
}
|
|
|
|
// ── Billscroll page (single full-aperture applet) ──────────────────────────
|
|
|
|
.billscroll-page {
|
|
@extend %billboard-page-base;
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0.75rem;
|
|
|
|
// The single scroll applet stretches to fill the remaining aperture
|
|
.applet-scroll {
|
|
@extend %applet-box;
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
|
|
#id_drama_scroll {
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
padding-right: 0.75rem;
|
|
|
|
.scroll-buffer {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: baseline;
|
|
padding: 2rem 0 1rem;
|
|
opacity: 0.4;
|
|
font-size: 0.8rem;
|
|
text-transform: uppercase;
|
|
|
|
.scroll-buffer-text {
|
|
letter-spacing: 0.33em;
|
|
}
|
|
|
|
.scroll-buffer-dots {
|
|
display: inline-flex;
|
|
letter-spacing: 0;
|
|
|
|
span {
|
|
display: inline-block;
|
|
width: 0.7em;
|
|
text-align: center;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Dashpost page (bottom-anchored thread + composer) ─────────────────────
|
|
// Mirrors billscroll's flex-column / overflow-y / scroll-buffer pattern,
|
|
// with the composer pinned at the bottom (flex-shrink: 0) so the thread
|
|
// breathes against the viewport bottom and the input stays in reach.
|
|
|
|
.post-page {
|
|
@extend %billboard-page-base;
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0.75rem;
|
|
gap: 0.5rem;
|
|
|
|
.post-header {
|
|
flex-shrink: 0;
|
|
|
|
.post-title {
|
|
margin: 0 0 0.25rem;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.post-shared-recipients,
|
|
.post-shared-self {
|
|
margin: 0;
|
|
font-size: 0.85rem;
|
|
opacity: 0.75;
|
|
}
|
|
}
|
|
|
|
#id_post_table {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0 0.75rem 0 0;
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
// Bottom-anchor: scroll buffer above the lines pushes them down
|
|
// until they fill from the bottom; once content exceeds the
|
|
// aperture, normal scrolling kicks in.
|
|
justify-content: flex-end;
|
|
|
|
.post-line {
|
|
display: grid;
|
|
grid-template-columns: minmax(4rem, auto) 1fr minmax(3rem, auto);
|
|
align-items: baseline;
|
|
gap: 0.5rem;
|
|
padding: 0.25rem 0;
|
|
|
|
.post-line-author {
|
|
font-weight: bold;
|
|
opacity: 0.75;
|
|
white-space: nowrap;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.post-line-text {
|
|
min-width: 0;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.post-line-time {
|
|
font-size: 0.75rem;
|
|
opacity: 0.5;
|
|
text-align: right;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
// System-authored Lines (adman) get a subtler typographic key
|
|
// — the inline `<a class="note-ref">` carries the emphasis.
|
|
&.post-line--system .post-line-text {
|
|
font-style: italic;
|
|
opacity: 0.85;
|
|
}
|
|
}
|
|
|
|
.post-line-buffer {
|
|
flex-shrink: 0;
|
|
height: 0.25rem;
|
|
}
|
|
}
|
|
|
|
.post-line-form {
|
|
flex-shrink: 0;
|
|
margin: 0;
|
|
padding-top: 0.25rem;
|
|
|
|
input.form-control {
|
|
width: 100%;
|
|
|
|
// Admin-Post readonly input — no response is invited, so the
|
|
// focus halo softens to --secUser (cooler than the regular
|
|
// --terUser glow used on user-Post composers).
|
|
&[readonly]:focus {
|
|
border-color: rgba(var(--secUser), 0.6);
|
|
box-shadow: 0 0 0.75rem rgba(var(--secUser), 0.4);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Billboard applet placement ─────────────────────────────────────────────
|
|
// Left column (4-wide): My Scrolls → Contacts → Notes stacked.
|
|
// Right column (8-wide): Most Recent Scroll spans full height.
|
|
// Portrait override (container query) restores stacked full-width layout.
|
|
|
|
#id_billboard_applets_container {
|
|
#id_applet_my_scrolls { grid-column: 1 / span 4; grid-row: 1 / span 3; }
|
|
#id_applet_my_contacts { grid-column: 1 / span 4; grid-row: 4 / span 3; }
|
|
#id_applet_notes { grid-column: 1 / span 4; grid-row: 7 / span 4; }
|
|
#id_applet_most_recent_scroll { grid-column: 5 / span 8; grid-row: 1 / span 10; }
|
|
|
|
@container (max-width: 550px) {
|
|
#id_applet_my_scrolls,
|
|
#id_applet_my_contacts,
|
|
#id_applet_notes,
|
|
#id_applet_most_recent_scroll {
|
|
grid-column: 1 / span 12;
|
|
grid-row: span var(--applet-rows, 3);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Notes applet — vertical title ─────────────────────────────────────────
|
|
|
|
#id_applet_notes {
|
|
h2 {
|
|
writing-mode: vertical-rl;
|
|
transform: rotate(180deg);
|
|
margin: 0;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
}
|
|
|
|
// ── Most Recent Scroll applet — scrollable drama feed ─────────────────────
|
|
|
|
#id_applet_most_recent_scroll {
|
|
display: flex;
|
|
flex-direction: column;
|
|
|
|
.most-recent-room-link {
|
|
flex-shrink: 0;
|
|
margin-bottom: 0.25rem;
|
|
font-weight: bold;
|
|
}
|
|
|
|
#id_drama_scroll {
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.most-recent-load-more {
|
|
display: block;
|
|
padding-bottom: 0.5rem;
|
|
font-size: 0.8rem;
|
|
text-align: center;
|
|
}
|
|
}
|
|
|
|
// ── Drama event entries: 90 / 10 column split ─────────────────────────────
|
|
|
|
.drama-event {
|
|
display: flex;
|
|
align-items: baseline;
|
|
|
|
.drama-event-body {
|
|
flex: 0 0 80%;
|
|
|
|
&.struck {
|
|
text-decoration: line-through;
|
|
opacity: 0.5;
|
|
}
|
|
}
|
|
|
|
.drama-event-time {
|
|
flex: 0 0 20%;
|
|
font-size: 0.75rem;
|
|
opacity: 0.5;
|
|
text-align: right;
|
|
}
|
|
}
|
|
|
|
// ── My Scrolls list ────────────────────────────────────────────────────────
|
|
|
|
#id_applet_my_scrolls {
|
|
.scroll-list {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
overflow-y: auto;
|
|
|
|
li {
|
|
padding: 0.25rem 0;
|
|
border-bottom: 1px solid rgba(var(--priUser), 0.15);
|
|
|
|
&:last-child { border-bottom: none; }
|
|
|
|
a { text-decoration: none; }
|
|
}
|
|
}
|
|
}
|