Valider fase 13–14: fiks size-inkonsistens, sikkerhet og konsistens i traits + publisering
Feil funnet og fikset:
1. truncate() UTF-8-panikkfeil: Brukte byte-indeksering s[..max] som
panikker på flerbyte-tegn (æ, ø, å). Byttet til char_indices().
2. VALID_TRAITS manglet 'mixer' og 'ai_tool': Dokumentert i
docs/primitiver/traits.md men avvist av backend-validering.
3. Planlagte artikler synlige før publish_at: serve_article og alle
listevisninger (forside, arkiv, kategori, søk) eksponerte artikler
med fremtidig publish_at. Lagt til tidsfiltere i alle spørringer.
4. A/B klikk-attribusjon logget alle varianter: serve_article logget
klikk for ALLE aktive varianter ved direkte artikkelbesøk, ikke
bare den viste. Fjernet feilaktig attribusjon — klikk logges kun
via track_click-endepunktet med spesifikk variant-parameter.
5. JSON-LD XSS via </script>: serde_json escaper ikke </script>-
sekvenser, så brukertitler kunne bryte ut av <script>-blokken.
Lagt til .replace("</", "<\\/") etter serialisering.
6. Hardkodet farge i search.html: Brukte rgba(233,69,96,0.1) i stedet
for tema-variabel. Byttet til color-mix() med --color-accent.
Nye tester: truncate med UTF-8, truncate kort streng, JSON-LD XSS-escape.
This commit is contained in:
parent
cebda9f3e8
commit
e25b5a11ef
4 changed files with 88 additions and 40 deletions
|
|
@ -36,7 +36,7 @@ const VALID_TRAITS: &[&str] = &[
|
||||||
// Publisering & distribusjon
|
// Publisering & distribusjon
|
||||||
"publishing", "rss", "newsletter", "custom_domain", "analytics", "embed", "api",
|
"publishing", "rss", "newsletter", "custom_domain", "analytics", "embed", "api",
|
||||||
// Lyd & video
|
// Lyd & video
|
||||||
"podcast", "recording", "transcription", "tts", "clips", "playlist", "studio",
|
"podcast", "recording", "transcription", "tts", "clips", "playlist", "mixer", "studio",
|
||||||
// Kommunikasjon
|
// Kommunikasjon
|
||||||
"chat", "forum", "comments", "guest_input", "announcements", "polls", "qa",
|
"chat", "forum", "comments", "guest_input", "announcements", "polls", "qa",
|
||||||
// Organisering
|
// Organisering
|
||||||
|
|
@ -44,7 +44,7 @@ const VALID_TRAITS: &[&str] = &[
|
||||||
// Kunnskap
|
// Kunnskap
|
||||||
"knowledge_graph", "wiki", "glossary", "faq", "bibliography",
|
"knowledge_graph", "wiki", "glossary", "faq", "bibliography",
|
||||||
// Automatisering & AI
|
// Automatisering & AI
|
||||||
"auto_tag", "auto_summarize", "digest", "bridge", "moderation",
|
"auto_tag", "auto_summarize", "digest", "bridge", "moderation", "ai_tool",
|
||||||
// Tilgang & fellesskap
|
// Tilgang & fellesskap
|
||||||
"membership", "roles", "invites", "paywall", "directory",
|
"membership", "roles", "invites", "paywall", "directory",
|
||||||
// Ekstern integrasjon
|
// Ekstern integrasjon
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,6 @@ fn build_json_ld(
|
||||||
publisher_name: &str,
|
publisher_name: &str,
|
||||||
canonical_url: &str,
|
canonical_url: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
// Escape for safe JSON embedding i <script>-tag
|
|
||||||
let ld = serde_json::json!({
|
let ld = serde_json::json!({
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Article",
|
"@type": "Article",
|
||||||
|
|
@ -152,7 +151,9 @@ fn build_json_ld(
|
||||||
},
|
},
|
||||||
"description": article.summary.as_deref().unwrap_or("")
|
"description": article.summary.as_deref().unwrap_or("")
|
||||||
});
|
});
|
||||||
ld.to_string()
|
// Escape </script> sekvenser for sikker embedding i <script>-tag.
|
||||||
|
// Uten dette kan brukerinnhold bryte ut av JSON-LD-blokken (XSS).
|
||||||
|
ld.to_string().replace("</", "<\\/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -330,8 +331,6 @@ pub struct CachedIndex {
|
||||||
/// Brukes for impression-logging ved serve.
|
/// Brukes for impression-logging ved serve.
|
||||||
/// Tuple: (edge_id, article_id)
|
/// Tuple: (edge_id, article_id)
|
||||||
active_ab_variants: Vec<(Uuid, Uuid)>,
|
active_ab_variants: Vec<(Uuid, Uuid)>,
|
||||||
/// Map fra article_id til aktive variant edge_ids (for klikk-attribusjon).
|
|
||||||
ab_article_variants: HashMap<Uuid, Vec<Uuid>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Thread-safe cache for forside-rendering (dynamisk modus).
|
/// Thread-safe cache for forside-rendering (dynamisk modus).
|
||||||
|
|
@ -863,6 +862,11 @@ async fn fetch_article(
|
||||||
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
|
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
|
||||||
.unwrap_or(created_at);
|
.unwrap_or(created_at);
|
||||||
|
|
||||||
|
// Artikler med fremtidig publish_at er ikke tilgjengelige ennå
|
||||||
|
if publish_at > Utc::now() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
// Sjekk om det finnes rendret HTML i CAS
|
// Sjekk om det finnes rendret HTML i CAS
|
||||||
let html_hash = node_metadata
|
let html_hash = node_metadata
|
||||||
.get("rendered")
|
.get("rendered")
|
||||||
|
|
@ -964,6 +968,7 @@ async fn fetch_index_articles_optimized(
|
||||||
type Row = (Uuid, Option<String>, Option<String>, DateTime<Utc>, Option<serde_json::Value>);
|
type Row = (Uuid, Option<String>, Option<String>, DateTime<Utc>, Option<serde_json::Value>);
|
||||||
|
|
||||||
// 1. Hero: slot = "hero", maks 1
|
// 1. Hero: slot = "hero", maks 1
|
||||||
|
// Ekskluderer artikler med fremtidig publish_at (planlagt publisering)
|
||||||
let hero_row: Option<Row> = sqlx::query_as(
|
let hero_row: Option<Row> = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT n.id, n.title, n.content, n.created_at, e.metadata
|
SELECT n.id, n.title, n.content, n.created_at, e.metadata
|
||||||
|
|
@ -972,6 +977,8 @@ async fn fetch_index_articles_optimized(
|
||||||
WHERE e.target_id = $1
|
WHERE e.target_id = $1
|
||||||
AND e.edge_type = 'belongs_to'
|
AND e.edge_type = 'belongs_to'
|
||||||
AND e.metadata->>'slot' = 'hero'
|
AND e.metadata->>'slot' = 'hero'
|
||||||
|
AND (e.metadata->>'publish_at' IS NULL
|
||||||
|
OR (e.metadata->>'publish_at')::timestamptz <= now())
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -992,6 +999,8 @@ async fn fetch_index_articles_optimized(
|
||||||
WHERE e.target_id = $1
|
WHERE e.target_id = $1
|
||||||
AND e.edge_type = 'belongs_to'
|
AND e.edge_type = 'belongs_to'
|
||||||
AND e.metadata->>'slot' = 'featured'
|
AND e.metadata->>'slot' = 'featured'
|
||||||
|
AND (e.metadata->>'publish_at' IS NULL
|
||||||
|
OR (e.metadata->>'publish_at')::timestamptz <= now())
|
||||||
ORDER BY (e.metadata->>'slot_order')::int ASC NULLS LAST
|
ORDER BY (e.metadata->>'slot_order')::int ASC NULLS LAST
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
"#,
|
"#,
|
||||||
|
|
@ -1017,6 +1026,8 @@ async fn fetch_index_articles_optimized(
|
||||||
WHERE e.target_id = $1
|
WHERE e.target_id = $1
|
||||||
AND e.edge_type = 'belongs_to'
|
AND e.edge_type = 'belongs_to'
|
||||||
AND (e.metadata->>'slot' IS NULL OR e.metadata->>'slot' = '')
|
AND (e.metadata->>'slot' IS NULL OR e.metadata->>'slot' = '')
|
||||||
|
AND (e.metadata->>'publish_at' IS NULL
|
||||||
|
OR (e.metadata->>'publish_at')::timestamptz <= now())
|
||||||
ORDER BY COALESCE(
|
ORDER BY COALESCE(
|
||||||
(e.metadata->>'publish_at')::timestamptz,
|
(e.metadata->>'publish_at')::timestamptz,
|
||||||
n.created_at
|
n.created_at
|
||||||
|
|
@ -1323,12 +1334,18 @@ async fn fetch_presentation_elements_batch(
|
||||||
}
|
}
|
||||||
|
|
||||||
fn truncate(s: &str, max: usize) -> String {
|
fn truncate(s: &str, max: usize) -> String {
|
||||||
if s.len() <= max {
|
if s.chars().count() <= max {
|
||||||
return s.to_string();
|
return s.to_string();
|
||||||
}
|
}
|
||||||
match s[..max].rfind(' ') {
|
// Finn char-boundary for maks lengde
|
||||||
|
let byte_pos = s.char_indices()
|
||||||
|
.nth(max)
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.unwrap_or(s.len());
|
||||||
|
// Finn siste mellomrom før grensen
|
||||||
|
match s[..byte_pos].rfind(' ') {
|
||||||
Some(pos) => format!("{}…", &s[..pos]),
|
Some(pos) => format!("{}…", &s[..pos]),
|
||||||
None => format!("{}…", &s[..max]),
|
None => format!("{}…", &s[..byte_pos]),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1476,13 +1493,7 @@ pub async fn serve_index(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bygg article_id → variant edge_ids map for klikk-attribusjon
|
// Legg i cache med aktive A/B-varianter (for impression-logging)
|
||||||
let mut ab_article_variants: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
|
|
||||||
for &(edge_id, article_id) in &ab_variants {
|
|
||||||
ab_article_variants.entry(article_id).or_default().push(edge_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legg i cache med aktive A/B-varianter
|
|
||||||
let expires_at = Utc::now() + chrono::Duration::seconds(cache_ttl as i64);
|
let expires_at = Utc::now() + chrono::Duration::seconds(cache_ttl as i64);
|
||||||
{
|
{
|
||||||
let mut cache = state.index_cache.write().await;
|
let mut cache = state.index_cache.write().await;
|
||||||
|
|
@ -1490,7 +1501,6 @@ pub async fn serve_index(
|
||||||
html: html.clone(),
|
html: html.clone(),
|
||||||
expires_at,
|
expires_at,
|
||||||
active_ab_variants: ab_variants,
|
active_ab_variants: ab_variants,
|
||||||
ab_article_variants,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1531,25 +1541,11 @@ pub async fn serve_article(
|
||||||
})?
|
})?
|
||||||
.ok_or(StatusCode::NOT_FOUND)?;
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
// A/B klikk-attribusjon: sjekk om denne artikkelen har aktive varianter
|
// A/B klikk-attribusjon er håndtert via track_click-endepunktet
|
||||||
// i den cachede forsiden, og logg klikk for dem.
|
// (/pub/{slug}/t/{short_id}?v={edge_id}) som logger klikk for
|
||||||
if let Ok(uid) = fetched.article.id.parse::<Uuid>() {
|
// den spesifikke varianten brukeren så. Direkte artikkelvisninger
|
||||||
let cache = state.index_cache.read().await;
|
// uten variant-parameter logges ikke — dette forhindrer at
|
||||||
if let Some(cached) = cache.get(&collection.id) {
|
// klikk attribueres feil variant.
|
||||||
if let Some(variant_edges) = cached.ab_article_variants.get(&uid) {
|
|
||||||
let db = state.db.clone();
|
|
||||||
let cid = collection.id;
|
|
||||||
let edges = variant_edges.clone();
|
|
||||||
let aid = uid;
|
|
||||||
drop(cache); // Release read lock before spawning
|
|
||||||
tokio::spawn(async move {
|
|
||||||
for edge_id in edges {
|
|
||||||
log_ab_click(&db, edge_id, aid, cid).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sjekk om pre-rendret HTML finnes i CAS
|
// Sjekk om pre-rendret HTML finnes i CAS
|
||||||
if let Some(ref hash) = fetched.html_hash {
|
if let Some(ref hash) = fetched.html_hash {
|
||||||
|
|
@ -2010,6 +2006,7 @@ pub async fn serve_category(
|
||||||
let tag_name = tag_title.unwrap_or_else(|| tag_slug.clone());
|
let tag_name = tag_title.unwrap_or_else(|| tag_slug.clone());
|
||||||
|
|
||||||
// Tell totalt antall artikler med denne taggen i samlingen
|
// Tell totalt antall artikler med denne taggen i samlingen
|
||||||
|
// Ekskluderer artikler med fremtidig publish_at
|
||||||
let (total_count,): (i64,) = sqlx::query_as(
|
let (total_count,): (i64,) = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
|
|
@ -2019,6 +2016,8 @@ pub async fn serve_category(
|
||||||
AND e_belongs.edge_type = 'belongs_to'
|
AND e_belongs.edge_type = 'belongs_to'
|
||||||
AND e_tag.target_id = $2
|
AND e_tag.target_id = $2
|
||||||
AND e_tag.edge_type = 'tagged'
|
AND e_tag.edge_type = 'tagged'
|
||||||
|
AND (e_belongs.metadata->>'publish_at' IS NULL
|
||||||
|
OR (e_belongs.metadata->>'publish_at')::timestamptz <= now())
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(collection.id)
|
.bind(collection.id)
|
||||||
|
|
@ -2042,6 +2041,8 @@ pub async fn serve_category(
|
||||||
AND e_belongs.edge_type = 'belongs_to'
|
AND e_belongs.edge_type = 'belongs_to'
|
||||||
AND e_tag.target_id = $2
|
AND e_tag.target_id = $2
|
||||||
AND e_tag.edge_type = 'tagged'
|
AND e_tag.edge_type = 'tagged'
|
||||||
|
AND (e_belongs.metadata->>'publish_at' IS NULL
|
||||||
|
OR (e_belongs.metadata->>'publish_at')::timestamptz <= now())
|
||||||
ORDER BY COALESCE(
|
ORDER BY COALESCE(
|
||||||
(e_belongs.metadata->>'publish_at')::timestamptz,
|
(e_belongs.metadata->>'publish_at')::timestamptz,
|
||||||
n.created_at
|
n.created_at
|
||||||
|
|
@ -2150,12 +2151,14 @@ pub async fn serve_archive(
|
||||||
.unwrap());
|
.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tell totalt
|
// Tell totalt (ekskluder fremtidige artikler)
|
||||||
let (total_count,): (i64,) = sqlx::query_as(
|
let (total_count,): (i64,) = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM edges e
|
FROM edges e
|
||||||
WHERE e.target_id = $1 AND e.edge_type = 'belongs_to'
|
WHERE e.target_id = $1 AND e.edge_type = 'belongs_to'
|
||||||
|
AND (e.metadata->>'publish_at' IS NULL
|
||||||
|
OR (e.metadata->>'publish_at')::timestamptz <= now())
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(collection.id)
|
.bind(collection.id)
|
||||||
|
|
@ -2175,6 +2178,8 @@ pub async fn serve_archive(
|
||||||
JOIN nodes n ON n.id = e.source_id
|
JOIN nodes n ON n.id = e.source_id
|
||||||
WHERE e.target_id = $1
|
WHERE e.target_id = $1
|
||||||
AND e.edge_type = 'belongs_to'
|
AND e.edge_type = 'belongs_to'
|
||||||
|
AND (e.metadata->>'publish_at' IS NULL
|
||||||
|
OR (e.metadata->>'publish_at')::timestamptz <= now())
|
||||||
ORDER BY COALESCE(
|
ORDER BY COALESCE(
|
||||||
(e.metadata->>'publish_at')::timestamptz,
|
(e.metadata->>'publish_at')::timestamptz,
|
||||||
n.created_at
|
n.created_at
|
||||||
|
|
@ -2332,6 +2337,8 @@ pub async fn serve_search(
|
||||||
WHERE e.target_id = $1
|
WHERE e.target_id = $1
|
||||||
AND e.edge_type = 'belongs_to'
|
AND e.edge_type = 'belongs_to'
|
||||||
AND n.search_vector @@ plainto_tsquery('norwegian', $2)
|
AND n.search_vector @@ plainto_tsquery('norwegian', $2)
|
||||||
|
AND (e.metadata->>'publish_at' IS NULL
|
||||||
|
OR (e.metadata->>'publish_at')::timestamptz <= now())
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(collection.id)
|
.bind(collection.id)
|
||||||
|
|
@ -2353,6 +2360,8 @@ pub async fn serve_search(
|
||||||
WHERE e.target_id = $1
|
WHERE e.target_id = $1
|
||||||
AND e.edge_type = 'belongs_to'
|
AND e.edge_type = 'belongs_to'
|
||||||
AND n.search_vector @@ plainto_tsquery('norwegian', $2)
|
AND n.search_vector @@ plainto_tsquery('norwegian', $2)
|
||||||
|
AND (e.metadata->>'publish_at' IS NULL
|
||||||
|
OR (e.metadata->>'publish_at')::timestamptz <= now())
|
||||||
ORDER BY ts_rank(n.search_vector, plainto_tsquery('norwegian', $2)) DESC
|
ORDER BY ts_rank(n.search_vector, plainto_tsquery('norwegian', $2)) DESC
|
||||||
LIMIT $3 OFFSET $4
|
LIMIT $3 OFFSET $4
|
||||||
"#,
|
"#,
|
||||||
|
|
@ -3350,6 +3359,46 @@ mod tests {
|
||||||
assert_eq!(css, css_blogg);
|
assert_eq!(css, css_blogg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_handles_multibyte_utf8() {
|
||||||
|
// Norsk tekst med æ, ø, å skal ikke panikke
|
||||||
|
let text = "Blåbærsyltetøy er en norsk spesialitet som smaker godt";
|
||||||
|
let result = truncate(text, 15);
|
||||||
|
assert!(result.ends_with('…') || result.len() <= 15 * 4);
|
||||||
|
// Sjekk at resultatet er gyldig UTF-8 (implisitt ved å bruke String)
|
||||||
|
assert!(!result.is_empty());
|
||||||
|
|
||||||
|
// Emoji / 4-byte chars
|
||||||
|
let emoji_text = "🎵🎶🎸🎹🎺🎻🎷 Musikk er flott";
|
||||||
|
let result2 = truncate(emoji_text, 5);
|
||||||
|
assert!(!result2.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_short_string_unchanged() {
|
||||||
|
let text = "Kort tekst";
|
||||||
|
assert_eq!(truncate(text, 20), "Kort tekst");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_ld_escapes_script_tags() {
|
||||||
|
let article = ArticleData {
|
||||||
|
id: "xss".to_string(),
|
||||||
|
short_id: "xss12345".to_string(),
|
||||||
|
title: "Test</script><script>alert(1)".to_string(),
|
||||||
|
subtitle: None,
|
||||||
|
content: "Safe".to_string(),
|
||||||
|
summary: Some("Safe summary".to_string()),
|
||||||
|
og_image: None,
|
||||||
|
published_at: "2026-03-18T12:00:00Z".to_string(),
|
||||||
|
published_at_short: "18. mars 2026".to_string(),
|
||||||
|
};
|
||||||
|
let ld = build_json_ld(&article, "Pub", "https://example.com/xss");
|
||||||
|
// Skal ikke inneholde rå </script>
|
||||||
|
assert!(!ld.contains("</script>"), "JSON-LD inneholder uescaped </script>: {ld}");
|
||||||
|
assert!(ld.contains("<\\/script>"), "JSON-LD mangler escaped </script>: {ld}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn z_test_insufficient_data_returns_1() {
|
fn z_test_insufficient_data_returns_1() {
|
||||||
// For lite data: returnerer p=1.0 (ingen signifikans)
|
// For lite data: returnerer p=1.0 (ingen signifikans)
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.article-list__highlight {
|
.article-list__highlight {
|
||||||
background: rgba(233, 69, 96, 0.1);
|
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||||
padding: 0 0.15rem;
|
padding: 0 0.15rem;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -300,8 +300,7 @@ med spesifikasjon for det som trenger en dedikert sesjon.
|
||||||
- [x] 23.3 Valider fase 5–8 (kommunikasjon + CAS + lyd + aliaser): chat-loop, kontekst-arv, CAS-hashing/deduplisering, Whisper-pipeline, segmenttabell, SRT-eksport, alias-identitet.
|
- [x] 23.3 Valider fase 5–8 (kommunikasjon + CAS + lyd + aliaser): chat-loop, kontekst-arv, CAS-hashing/deduplisering, Whisper-pipeline, segmenttabell, SRT-eksport, alias-identitet.
|
||||||
- [x] 23.4 Valider fase 9–10 (visninger + AI): kanban drag-and-drop, kalender, dagbok, kunnskapsgraf, LiteLLM-ruting, AI-foreslåtte edges, oppsummering, TTS.
|
- [x] 23.4 Valider fase 9–10 (visninger + AI): kanban drag-and-drop, kalender, dagbok, kunnskapsgraf, LiteLLM-ruting, AI-foreslåtte edges, oppsummering, TTS.
|
||||||
- [x] 23.5 Valider fase 11 (produksjon): LiveKit-oppsett, sanntidslyd, pruning-logikk, podcast-RSS.
|
- [x] 23.5 Valider fase 11 (produksjon): LiveKit-oppsett, sanntidslyd, pruning-logikk, podcast-RSS.
|
||||||
- [~] 23.6 Valider fase 13–14 (traits + publisering): trait-validering, pakkevelger, Tera-templates, HTML-rendering, forside, slot-håndtering, redaksjonell flyt, planlagt publisering, A/B-testing.
|
- [x] 23.6 Valider fase 13–14 (traits + publisering): trait-validering, pakkevelger, Tera-templates, HTML-rendering, forside, slot-håndtering, redaksjonell flyt, planlagt publisering, A/B-testing.
|
||||||
> Påbegynt: 2026-03-18T15:16
|
|
||||||
- [ ] 23.7 Valider fase 15–16 (admin + lydmixer): systemvarsler, graceful shutdown, jobbkø-oversikt, ressursstyring, serverhelse, Web Audio mixer, delt kontroll, sound pads, EQ, stemmeeffekter.
|
- [ ] 23.7 Valider fase 15–16 (admin + lydmixer): systemvarsler, graceful shutdown, jobbkø-oversikt, ressursstyring, serverhelse, Web Audio mixer, delt kontroll, sound pads, EQ, stemmeeffekter.
|
||||||
- [ ] 23.8 Valider fase 17–18 (lydstudio-utbedring + AI-verktøy): responsivt layout, FFmpeg-validering, fade/silence, AI-presets, direction-logikk, drag-and-drop integrasjon.
|
- [ ] 23.8 Valider fase 17–18 (lydstudio-utbedring + AI-verktøy): responsivt layout, FFmpeg-validering, fade/silence, AI-presets, direction-logikk, drag-and-drop integrasjon.
|
||||||
- [ ] 23.9 Valider fase 19–20 (arbeidsflaten + universell overføring): canvas pan/zoom, BlockShell, layout-persistering, snarveier, transfer service, alle panelreworks (chat, kanban, kalender, editor, studio).
|
- [ ] 23.9 Valider fase 19–20 (arbeidsflaten + universell overføring): canvas pan/zoom, BlockShell, layout-persistering, snarveier, transfer service, alle panelreworks (chat, kanban, kalender, editor, studio).
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue